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
@@ -8,6 +8,14 @@
8
8
  # (https://kamal-deploy.org/docs/hooks/overview/), so it POSTs straight to the
9
9
  # ingest host instead of shelling into a container. Never fails a deploy.
10
10
  set -e
11
+
12
+ # Embedded install (transport = :local): the marker is a row in the app's own
13
+ # railwatch database, so record it inside the deployed container and stop.
14
+ if [ "${RAILWATCH_TRANSPORT:-}" = "local" ]; then
15
+ bin/kamal app exec --primary --reuse "bin/rails railwatch:deploy[$KAMAL_VERSION]" || true
16
+ exit 0
17
+ fi
18
+
11
19
  [ -z "$RAILWATCH_TOKEN" ] && exit 0
12
20
 
13
21
  # Optional: .kamal/hooks/post-deploy --sourcemaps, or set
@@ -0,0 +1,170 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "puma/plugin"
4
+
5
+ # `plugin :railwatch` in config/puma.rb. In embedded mode, forks one
6
+ # Railwatch::Writer from the Puma process once it has booted, restarts it if
7
+ # it dies, and stops it with Puma. Same shape as Solid Queue's
8
+ # `solid_queue_mode :fork`, in both cluster and single mode: a default Rails
9
+ # 8 app runs Puma in single mode (no WEB_CONCURRENCY), and that app's
10
+ # batches belong off its request threads just as much. The fork happens
11
+ # from the plugin's background thread with the app loaded and Rails'
12
+ # ForkTracker resetting every Railwatch thread in the child, exactly as it
13
+ # does for a Puma cluster worker. A no-op when Railwatch is off or the
14
+ # transport is not :local, so it is safe to leave in place.
15
+ #
16
+ # Nothing in `start` may touch Railwatch: `bundle exec puma` fires plugin
17
+ # starts BEFORE it loads the Rack app, so the constant may not exist yet.
18
+ # Every decision that needs the app is made from the background supervisor,
19
+ # which waits for after_booted.
20
+ Puma::Plugin.create do
21
+ attr_reader :log_writer, :writer_pid
22
+
23
+ POLL = 2
24
+
25
+ def start(launcher)
26
+ @log_writer = launcher.log_writer
27
+ @puma_pid = $$
28
+ @launcher = launcher
29
+ @shutting_down = false
30
+ @booted = false
31
+ @warned = false
32
+
33
+ # When the app is already loaded (`bin/rails server` loads it before
34
+ # handing over to Puma), mark a writer expected now: cluster workers fork
35
+ # from this process after start and inherit the flag, so they retain
36
+ # batches while the writer is starting instead of writing them in-process.
37
+ # Under bare `puma` this is false and the workers find the writer on
38
+ # their transport's next re-check instead (Transport::Socket).
39
+ ::Railwatch::Writer.expected! if active?
40
+
41
+ # in_background blocks are collected at plugin start and started by the
42
+ # runner once, so this has to be registered here, not from after_booted
43
+ # (by then they have already been fired and a late block never runs). It
44
+ # waits for boot itself.
45
+ in_background { supervise }
46
+
47
+ launcher.events.after_booted { booted! }
48
+ # A phased restart fires before_restart and then, once the new workers
49
+ # are up, after_booted again. The writer is stopped for the restart and
50
+ # the supervisor, which is still running, respawns it when @booted flips
51
+ # back. Only a real stop latches shutdown.
52
+ launcher.events.before_restart { restart_writer }
53
+ # after_stopped only latches. It is NOT where the writer is stopped:
54
+ # Puma's SIGTERM trap fires it BEFORE `stop_blocked` drains in-flight
55
+ # requests, so stopping here would take the writer away from requests
56
+ # that are still running. at_exit runs after the whole run loop.
57
+ launcher.events.after_stopped { @shutting_down = true }
58
+ at_exit { shutdown_writer if Process.pid == @puma_pid }
59
+ end
60
+
61
+ private
62
+
63
+ def booted!
64
+ @booted = true
65
+ ::Railwatch::Writer.expected! if active?
66
+ end
67
+
68
+ # Whether this process should run a writer. Answered fresh each time, and
69
+ # safe before the app is loaded: `defined?(Railwatch)` is true as soon as
70
+ # the gem's entrypoint has been required (Bundler.require), but a bare
71
+ # `puma` on an app that has not required it yet sees neither, and must not
72
+ # raise out of Puma's boot.
73
+ def active?
74
+ return false unless defined?(::Railwatch) && ::Railwatch.respond_to?(:enabled?)
75
+ return false unless ::Railwatch.enabled? && ::Railwatch.config.local?
76
+
77
+ path = ::Railwatch.config.writer_socket_path
78
+ return false if path.nil?
79
+ return true if ::Railwatch::Writer.usable_path?(path)
80
+
81
+ unless @warned
82
+ @warned = true
83
+ log "Railwatch writer not started: socket path #{path} is #{path.bytesize} bytes, over the " \
84
+ "#{::Railwatch::Writer::MAX_SOCKET_PATH}-byte limit; set RAILWATCH_WRITER_SOCKET to a shorter path. " \
85
+ "Batches are written in-process meanwhile."
86
+ end
87
+ false
88
+ end
89
+
90
+ def spawn_writer
91
+ @writer_pid = ::Railwatch::Writer.fork_writer! do
92
+ ::Railwatch::Writer.run!(parent: @puma_pid)
93
+ end
94
+ log "Railwatch writer started (pid #{@writer_pid})"
95
+ rescue SystemCallError => e
96
+ @writer_pid = nil
97
+ log "Railwatch writer could not be forked (#{e.class}: #{e.message}); retrying"
98
+ end
99
+
100
+ # Puma's cluster reaps every child with wait2(-1), the writer included, so
101
+ # waitpid on the writer's pid raises ECHILD after it has died. Liveness is
102
+ # asked with signal 0 instead, which works whoever reaped it.
103
+ def supervise
104
+ loop do
105
+ sleep POLL
106
+ break if @shutting_down
107
+ next unless @booted && active?
108
+ next if writer_alive?
109
+
110
+ log "Railwatch writer (pid #{@writer_pid}) is gone; starting" if @writer_pid
111
+ spawn_writer
112
+ end
113
+ end
114
+
115
+ def writer_alive?
116
+ return false unless @writer_pid
117
+
118
+ Process.kill(0, @writer_pid)
119
+ # A zombie the cluster has not reaped yet still answers signal 0.
120
+ !Process.waitpid(@writer_pid, Process::WNOHANG)
121
+ rescue Errno::ESRCH
122
+ false
123
+ rescue Errno::ECHILD
124
+ # Already reaped by the cluster: alive iff it still exists for kill 0.
125
+ begin
126
+ Process.kill(0, @writer_pid)
127
+ true
128
+ rescue Errno::ESRCH
129
+ false
130
+ end
131
+ end
132
+
133
+ # For a restart: stop the writer and mark the cluster as not booted, so the
134
+ # supervisor spawns a fresh one once after_booted fires again.
135
+ def restart_writer
136
+ @booted = false
137
+ stop_writer
138
+ end
139
+
140
+ # Runs from at_exit, after Puma's run loop has returned and every request
141
+ # has been served, in both single and cluster mode. Flushes this process's
142
+ # own reporter (its process and health records, and in single mode the
143
+ # requests' records) while the writer is still listening, then stops it.
144
+ def shutdown_writer
145
+ @shutting_down = true
146
+ if defined?(::Railwatch) && ::Railwatch.respond_to?(:enabled?) && ::Railwatch.enabled? && ::Railwatch.config.local?
147
+ ::Railwatch.reporter.shutdown
148
+ end
149
+ stop_writer
150
+ rescue StandardError => e
151
+ log "Railwatch writer shutdown failed (#{e.class}: #{e.message})"
152
+ end
153
+
154
+ # TERM closes the writer's listener and it drains what it is holding; the
155
+ # wait also reaps it, so a cluster master never leaves a zombie behind.
156
+ def stop_writer
157
+ return unless @writer_pid
158
+
159
+ Process.kill(:TERM, @writer_pid)
160
+ Process.wait(@writer_pid)
161
+ rescue Errno::ECHILD, Errno::ESRCH
162
+ nil
163
+ ensure
164
+ @writer_pid = nil
165
+ end
166
+
167
+ def log(message)
168
+ log_writer.log(message)
169
+ end
170
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module Railwatch
6
+ # `bin/rails railwatch:authentication:configure`: stores the embedded
7
+ # dashboard's HTTP Basic credentials in the current environment's Rails
8
+ # credentials, the same way Mission Control Jobs' authentication:configure
9
+ # does. Asks for a user, generates the password, appends a `railwatch:`
10
+ # entry to the credentials file the environment reads.
11
+ module Authentication
12
+ module_function
13
+
14
+ def configure(io: $stdin, out: $stdout)
15
+ unless credentials_accessible?
16
+ out.puts "Rails credentials are not configured or the key is not available for `#{Rails.env}`. " \
17
+ "Set them up (`bin/rails credentials:help`) or set RAILWATCH_HTTP_BASIC_AUTH_USER and " \
18
+ "RAILWATCH_HTTP_BASIC_AUTH_PASSWORD in the environment instead."
19
+ return false
20
+ end
21
+ if configured?
22
+ out.puts "HTTP Basic authentication is already configured for `#{Rails.env}` (railwatch.http_basic_auth_user); " \
23
+ "edit it with `bin/rails credentials:edit#{env_flag}`."
24
+ return false
25
+ end
26
+
27
+ out.print "Enter username for the Railwatch dashboard (#{Rails.env}): "
28
+ username = io.gets.to_s.strip
29
+ if username.empty?
30
+ out.puts "No username given; nothing written."
31
+ return false
32
+ end
33
+ password = SecureRandom.base58(48)
34
+ # Merge, never append. A second top-level `railwatch:` mapping would
35
+ # win in Psych and silently hide whatever the first one held, and a
36
+ # username with YAML syntax in it would reshape the file; round-tripping
37
+ # through the parser and to_yaml quotes it correctly.
38
+ merged = (YAML.safe_load(credentials.read.to_s, permitted_classes: [ Symbol ], aliases: true) || {})
39
+ unless merged.is_a?(Hash)
40
+ out.puts "#{credentials.content_path} does not contain a YAML mapping; edit it by hand."
41
+ return false
42
+ end
43
+ merged["railwatch"] = (merged["railwatch"].is_a?(Hash) ? merged["railwatch"] : {})
44
+ .merge("http_basic_auth_user" => username, "http_basic_auth_password" => password)
45
+ credentials.write(merged.to_yaml)
46
+
47
+ out.puts <<~DONE
48
+ Stored in #{credentials.content_path.relative_path_from(Rails.root)} under `railwatch:`.
49
+
50
+ The dashboard at /railwatch now asks for:
51
+ user: #{username}
52
+ password: #{password}
53
+
54
+ Edit later with `bin/rails credentials:edit#{env_flag}`. Restart the app to pick it up.
55
+ DONE
56
+ true
57
+ end
58
+
59
+ def credentials_accessible?
60
+ credentials.read.present?
61
+ rescue ActiveSupport::EncryptedFile::MissingKeyError, ActiveSupport::MessageEncryptor::InvalidMessage
62
+ false
63
+ end
64
+
65
+ # Both halves, so a file holding only one of them is repaired rather than
66
+ # refused (which would leave the dashboard closed with no way forward).
67
+ def configured?
68
+ %i[http_basic_auth_user http_basic_auth_password].all? { |key| credentials.dig(:railwatch, key).present? }
69
+ end
70
+
71
+ # The file Rails.application.credentials reads for this environment:
72
+ # config/credentials/<env>.yml.enc when that environment has one, else
73
+ # config/credentials.yml.enc.
74
+ def credentials
75
+ config = Rails.application.config.credentials
76
+ Rails.application.encrypted(config.content_path, key_path: config.key_path)
77
+ end
78
+
79
+ def env_flag
80
+ Rails.root.join("config/credentials/#{Rails.env}.yml.enc").exist? ? " --environment #{Rails.env}" : ""
81
+ end
82
+ end
83
+ end
@@ -60,7 +60,15 @@ module Railwatch
60
60
  Rack::QueryParser::ParameterTypeError
61
61
  ].freeze
62
62
 
63
- attr_accessor :enabled, :token, :ingest_url, :allow_http, :server, :environment,
63
+ # What the dashboard controllers inherit from unless the host names its
64
+ # own. Also how `dashboard_gate` tells "they wired their own auth" from
65
+ # "they left the default".
66
+ DEFAULT_BASE_CONTROLLER = "ActionController::Base"
67
+
68
+ attr_accessor :enabled, :token, :ingest_url, :allow_http, :server, :environment, :transport,
69
+ :issue_prefix, :repository_url, :retention_days, :dashboard_user, :writer_socket,
70
+ :http_basic_auth_enabled, :http_basic_auth_user, :http_basic_auth_password, :base_controller_class,
71
+ :dashboard_open,
64
72
  :sample, :log_level, :capture_request_payload,
65
73
  :capture_exception_source, :capture_exception_locals, :redact_headers, :redact_params,
66
74
  :buffer_size, :buffer_bytes, :execution_buffer_bytes, :batch_bytes,
@@ -69,7 +77,8 @@ module Railwatch
69
77
  :connect_timeout, :timeout, :shutdown_timeout,
70
78
  :slow_query_threshold_ms, :n_plus_one_threshold,
71
79
  :max_view_renders_per_execution, :ignored_cache_key_prefixes,
72
- :beacon_enabled, :beacon_rate_limit, :debug, :capture_default_vendor_commands,
80
+ :beacon_enabled, :beacon_rate_limit, :beacon_global_rate_limit, :beacon_allowed_origins,
81
+ :debug, :capture_default_vendor_commands,
73
82
  :capture_default_vendor_cache_keys, :on_unrecoverable,
74
83
  :capture_framework_events,
75
84
  :tail_sample_slow_ms, :failure_context, :propagate_traces, :trace_propagation_hosts,
@@ -88,6 +97,39 @@ module Railwatch
88
97
  def initialize
89
98
  @enabled = env_bool("RAILWATCH_ENABLED", true)
90
99
  @token = ENV["RAILWATCH_TOKEN"]
100
+ @transport = ENV.fetch("RAILWATCH_TRANSPORT", "http").to_sym
101
+ # Embedded dashboard settings; ignored when transport is :http.
102
+ @issue_prefix = ENV["RAILWATCH_ISSUE_PREFIX"]
103
+ @repository_url = ENV["RAILWATCH_REPOSITORY_URL"]
104
+ @retention_days = env_int("RAILWATCH_RETENTION_DAYS", 7)
105
+ @dashboard_user = nil
106
+ # Dashboard access, the way Mission Control Jobs does it: HTTP Basic
107
+ # authentication is on and CLOSED by default. With no user and password
108
+ # configured every dashboard request is 401, so an install that forgot
109
+ # to set anything up is never a public page. Credentials come from
110
+ # Rails credentials (railwatch.http_basic_auth_user/_password, written
111
+ # by `bin/rails railwatch:authentication:configure`), from these env
112
+ # vars, or by assignment here. A host with its own admin auth turns
113
+ # Basic off and names a base controller, or wraps the mount in a
114
+ # routes constraint.
115
+ @http_basic_auth_enabled = env_bool("RAILWATCH_HTTP_BASIC_AUTH_ENABLED", true)
116
+ @http_basic_auth_user = ENV["RAILWATCH_HTTP_BASIC_AUTH_USER"]
117
+ @http_basic_auth_password = ENV["RAILWATCH_HTTP_BASIC_AUTH_PASSWORD"]
118
+ # Only consulted when HTTP Basic is off: the host saying, in as many
119
+ # words, "I accept that anyone who can reach this URL can read it".
120
+ # A private network or a VPN is a real answer; this is how you say so,
121
+ # and it is what silences the boot warning and opens the live channel.
122
+ @dashboard_open = env_bool("RAILWATCH_DASHBOARD_OPEN", false)
123
+ # The dashboard controllers inherit from this class, so a host's own
124
+ # before_actions (require an admin, redirect to sign-in) run in front of
125
+ # every page. Default is the engine's own base, which authenticates
126
+ # nothing itself beyond HTTP Basic above.
127
+ @base_controller_class = ENV.fetch("RAILWATCH_BASE_CONTROLLER_CLASS", DEFAULT_BASE_CONTROLLER)
128
+ # Embedded mode's writer process (lib/railwatch/writer.rb): the Unix
129
+ # socket the Puma workers hand their batches to. Relative paths are
130
+ # under Rails.root. nil disables the writer and every process writes
131
+ # its own batches, as before.
132
+ @writer_socket = ENV.fetch("RAILWATCH_WRITER_SOCKET", "tmp/sockets/railwatch-writer.sock")
91
133
  @ingest_url = ENV.fetch("RAILWATCH_INGEST_URL", "https://railwatch.rebulk.com")
92
134
  @allow_http = env_bool("RAILWATCH_ALLOW_HTTP", false)
93
135
  @project_root = defined?(Rails) ? Rails.root : Dir.pwd
@@ -147,6 +189,19 @@ module Railwatch
147
189
  # from a shell. Per client IP per minute; 0 turns the limit off, and a
148
190
  # negative value is normalized to 0 rather than left to mean anything.
149
191
  @beacon_rate_limit = [ env_int("RAILWATCH_BEACON_RATE_LIMIT", 120), 0 ].max
192
+ # The same three defences a hosted product puts in front of a public
193
+ # browser-ingest endpoint (Sentry's are allowed domains, per-key rate
194
+ # limits and spike protection): a per-IP limit above, a ceiling on the
195
+ # whole endpoint so one busy minute cannot fill the telemetry database,
196
+ # and an origin allowlist. None is authentication -- a public endpoint
197
+ # cannot have any, since the credential would be in the page -- they
198
+ # bound abuse. 0 disables a limit; beacon_enabled = false removes the
199
+ # endpoint's work entirely.
200
+ @beacon_global_rate_limit = [ env_int("RAILWATCH_BEACON_GLOBAL_RATE_LIMIT", 6_000), 0 ].max
201
+ # Extra origins allowed to beacon, beyond the app's own. Same meaning
202
+ # as Sentry's allowed domains: "https://app.example.com", or a host on
203
+ # its own. Empty means same-origin only.
204
+ @beacon_allowed_origins = ENV["RAILWATCH_BEACON_ALLOWED_ORIGINS"]&.split(",")&.map(&:strip)&.reject(&:empty?) || []
150
205
  @debug = env_bool("RAILWATCH_DEBUG", false)
151
206
  # Tail-based sampling: a head-sampled-out execution is still kept when
152
207
  # it ran at least this long, raised, or Railwatch.keep! was called. nil = off.
@@ -238,7 +293,83 @@ module Railwatch
238
293
  end
239
294
 
240
295
  def enabled?
241
- @enabled && token.present?
296
+ @enabled && (local? || token.present?)
297
+ end
298
+
299
+ # :local writes telemetry into the engine's own database in-process;
300
+ # anything else ships it to ingest_url over HTTPS.
301
+ def local? = transport.to_s == "local"
302
+
303
+ # Absolute path of the writer socket, or nil when the writer is off.
304
+ def writer_socket_path
305
+ path = writer_socket.to_s
306
+ return nil if path.empty?
307
+
308
+ root = defined?(Rails) && Rails.respond_to?(:root) && Rails.root ? Rails.root.to_s : Dir.pwd
309
+ File.expand_path(path, root)
310
+ end
311
+
312
+ # Who the embedded dashboard shows as the signed-in operator. A host
313
+ # passes a lambda taking the request (cookies, warden, whatever it uses)
314
+ # and returning a User, {id:, name:, email:} or nil. Authentication and
315
+ # authorisation stay the host's job: put the mount behind its own
316
+ # constraint. This only names the person for comments and saved views.
317
+ # Both halves present. Read late (not at boot) so credentials set from an
318
+ # initializer, an env var or the configure task all count.
319
+ def http_basic_auth_configured?
320
+ http_basic_auth_user.to_s.strip != "" && http_basic_auth_password.to_s.strip != ""
321
+ end
322
+
323
+ # Whether the request carries the configured HTTP Basic credentials.
324
+ # False when Basic is on and nothing is configured (closed), true when
325
+ # Basic is off (the host's base controller or routes constraint is the
326
+ # gate then). Shared by the dashboard controller and the live channel.
327
+ def http_basic_auth_ok?(request)
328
+ return true unless http_basic_auth_enabled
329
+ return false unless http_basic_auth_configured?
330
+
331
+ ActionController::HttpAuthentication::Basic.authenticate(request) do |user, password|
332
+ ActiveSupport::SecurityUtils.secure_compare(user, http_basic_auth_user.to_s) &
333
+ ActiveSupport::SecurityUtils.secure_compare(password, http_basic_auth_password.to_s)
334
+ end == true
335
+ end
336
+
337
+ # Which gate is in force, for the doctor, the boot warning and the live
338
+ # channel. :basic when HTTP Basic is on (closed until credentials exist),
339
+ # :controller when the host named its own base controller, :resolver when
340
+ # it gave a dashboard_user that can refuse, :open when it said the
341
+ # dashboard is deliberately public, and :undeclared when Basic is off and
342
+ # none of those is true -- which usually means a routes constraint the
343
+ # gem cannot see, and might mean nothing at all.
344
+ def dashboard_gate
345
+ return :basic if http_basic_auth_enabled
346
+ return :controller unless base_controller_class == DEFAULT_BASE_CONTROLLER
347
+ return :resolver if dashboard_user
348
+ return :open if dashboard_open
349
+
350
+ :undeclared
351
+ end
352
+
353
+ # Whether a live-update subscription is allowed. Action Cable runs on the
354
+ # host's own /cable endpoint, which a routes constraint around the
355
+ # engine's mount does not cover and a base controller cannot reach, so an
356
+ # undeclared gate refuses rather than assuming.
357
+ def dashboard_channel_allowed?(request)
358
+ case dashboard_gate
359
+ when :basic then http_basic_auth_ok?(request)
360
+ when :resolver then !(dashboard_user.respond_to?(:call) ? dashboard_user.call(request) : dashboard_user).nil?
361
+ when :open, :controller then true
362
+ else false
363
+ end
364
+ end
365
+
366
+ def resolve_dashboard_user(request)
367
+ resolved = dashboard_user.respond_to?(:call) ? dashboard_user.call(request) : dashboard_user
368
+ case resolved
369
+ when User then resolved
370
+ when Hash then User.new(id: resolved[:id] || User::ID, name: resolved[:name].to_s.presence || "Operator", email: resolved[:email])
371
+ else User.default
372
+ end
242
373
  end
243
374
 
244
375
  def ingest_url_allowed?
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rack/files"
4
+
5
+ module Railwatch
6
+ # Serves the prebuilt dashboard bundle the gem ships under public/railwatch.
7
+ # Vite wrote every asset URL with base /railwatch/assets/, so a request for
8
+ # /railwatch/assets/assets/app-abc123.js maps to public/railwatch/assets/
9
+ # app-abc123.js. Files are content-hashed, so they are immutable. The
10
+ # favicon is the one unhashed file, served with a short cache instead.
11
+ class DashboardAssets
12
+ PREFIX = "/railwatch/assets/"
13
+ ICONS = { "/railwatch/icon.svg" => "/icon.svg", "/railwatch/icon.png" => "/icon.png" }.freeze
14
+
15
+ # Put this middleware in the host's stack. Before ActionDispatch::Static
16
+ # when the host has it, which is what keeps a same-named file in the
17
+ # app's own public/ from shadowing the dashboard's. An app that serves
18
+ # static files from nginx, a CDN or Thruster sets
19
+ # `config.public_file_server.enabled = false` and has no such middleware:
20
+ # inserting relative to it would raise and take the whole application
21
+ # down at boot, so go to the front instead, which is the same position
22
+ # relative to everything that remains.
23
+ def self.install!(app, root:)
24
+ if app.config.public_file_server.enabled
25
+ app.middleware.insert_before ActionDispatch::Static, self, root: root
26
+ else
27
+ app.middleware.insert 0, self, root: root
28
+ end
29
+ end
30
+
31
+ def initialize(app, root:)
32
+ @app = app
33
+ @files = Rack::Files.new(root, { "cache-control" => "public, max-age=31536000, immutable" })
34
+ @icons = Rack::Files.new(root, { "cache-control" => "public, max-age=86400" })
35
+ end
36
+
37
+ def call(env)
38
+ path = env["PATH_INFO"]
39
+ return @icons.call(env.merge("PATH_INFO" => ICONS[path])) if ICONS.key?(path)
40
+ return @app.call(env) unless path.start_with?(PREFIX) && !path.include?("..")
41
+
42
+ @files.call(env.merge("PATH_INFO" => path.delete_prefix("/railwatch/assets")))
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railwatch
4
+ # Process-local state for the embedded (in-process) mode and the stand-ins
5
+ # the platform's code expects from its account layer.
6
+ module Embedded
7
+ # Relation-shaped empty set for platform associations an embedded install
8
+ # does not have (Linear links, integrations). Every scope returns itself.
9
+ NONE = Class.new do
10
+ def method_missing(*) = self
11
+ def respond_to_missing?(*) = true
12
+ def to_a = []
13
+ def each(&) = [].each(&)
14
+ def first = nil
15
+ def map(&) = []
16
+ def count = 0
17
+ def any? = false
18
+ def none? = true
19
+ def exists?(*) = false
20
+ def empty? = true
21
+ def limit(*) = self
22
+ def where(*) = self
23
+ def find_each(&) = nil
24
+ end.new.freeze
25
+
26
+ # The platform scopes everything by account (plan, quota, retention,
27
+ # members). One embedded install is one account with none of that.
28
+ module Account
29
+ module_function
30
+
31
+ def id = 1
32
+ def name = Rails.application.class.module_parent_name
33
+ def slug = name.parameterize
34
+ def plan = "embedded"
35
+ def retention_days = Railwatch.config.retention_days
36
+ def auto_resolve_after_days = 14
37
+ # Whoever the host's resolver named for this request, not the anonymous
38
+ # placeholder: an embedded install has no member list, so the account's
39
+ # one "member" is the person looking at it.
40
+ def users = [ User.current || User.default ]
41
+ def members = users
42
+ def memberships = NONE
43
+ def integrations = NONE
44
+ def applications = [ Application.current ]
45
+ def environments = [ Environment.current ]
46
+ def quota_exhausted? = false
47
+ def as_json(*) = { id: id, name: name, slug: slug, plan: plan }
48
+ end
49
+
50
+ class << self
51
+ attr_accessor :last_seen_at
52
+ end
53
+ end
54
+ end
@@ -1,11 +1,56 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # The dashboard controllers are Inertia controllers; a host that does not use
4
+ # Inertia itself never requires the gem, so the engine does.
5
+ require "inertia_rails"
6
+ # The engine's jobs (grouping, rollups, scans) are Active Job classes even
7
+ # though embedded mode calls their perform directly, so a host built with
8
+ # `rails new --minimal` (no Active Job) still needs the framework loaded.
9
+ # Part of the rails gem this one depends on, so nothing new is pulled in.
10
+ require "active_job/railtie"
11
+
3
12
  module Railwatch
13
+ # isolate_namespace would give every model a railwatch_ prefix. Only the
14
+ # engine's own records (issues, comments, deploys: Railwatch::ApplicationRecord)
15
+ # carry it; the telemetry tables are the hosted platform's schema and stay
16
+ # unprefixed. Defined before isolate_namespace runs, which only adds a
17
+ # prefix when none is set.
18
+ def self.table_name_prefix = ""
19
+
4
20
  class Engine < ::Rails::Engine
5
21
  isolate_namespace Railwatch
6
22
 
7
23
  config.railwatch = ActiveSupport::OrderedOptions.new
8
24
 
25
+ # The dashboard bundle is built into the gem at release time; serve it
26
+ # from here so a host app needs no asset pipeline integration at all.
27
+ # Where it goes in the stack is DashboardAssets.install!'s decision.
28
+ initializer "railwatch.dashboard_assets" do |app|
29
+ Railwatch::DashboardAssets.install!(app, root: root.join("public/railwatch").to_s)
30
+ end
31
+
32
+ # Action Cable is optional: live dashboard updates need it, nothing else
33
+ # does. A host without it (`rails new --minimal`, --skip-action-cable)
34
+ # must not eager-load the engine's channel, which inherits from a class
35
+ # that does not exist there.
36
+ initializer "railwatch.channels", before: :setup_main_autoloader do
37
+ Rails.autoloaders.main.ignore(root.join("app/channels")) unless defined?(::ActionCable)
38
+ end
39
+
40
+ # The dashboard bundle subscribes to "EnvironmentChannel" by name; Action
41
+ # Cable constantizes that, so the engine's channel needs the bare name.
42
+ # Only defined when the host has not got one of its own.
43
+ initializer "railwatch.live_channel" do
44
+ next unless defined?(::ActionCable)
45
+
46
+ # Action Cable constantizes the subscription's channel name at
47
+ # subscribe time; the alias only has to exist by then, and it must
48
+ # not clobber a host channel of the same name.
49
+ config.to_prepare do
50
+ Object.const_set(:EnvironmentChannel, Railwatch::EnvironmentChannel) unless Object.const_defined?(:EnvironmentChannel)
51
+ end
52
+ end
53
+
9
54
  # The request middleware goes first so wall time includes every other
10
55
  # middleware, exactly like Nightwatch's GlobalMiddleware.
11
56
  initializer "railwatch.middleware", before: :load_config_initializers do |app|
@@ -18,8 +63,39 @@ module Railwatch
18
63
  Railwatch::Console.silence!
19
64
  end
20
65
 
66
+ # Same source Mission Control Jobs reads: railwatch.http_basic_auth_user
67
+ # and _password in Rails credentials, which `bin/rails
68
+ # railwatch:authentication:configure` writes. An initializer or env var
69
+ # that already set them wins.
70
+ initializer "railwatch.http_basic_auth", after: :load_config_initializers do |app|
71
+ config = Railwatch.config
72
+ config.http_basic_auth_user ||= app.credentials.dig(:railwatch, :http_basic_auth_user)
73
+ config.http_basic_auth_password ||= app.credentials.dig(:railwatch, :http_basic_auth_password)
74
+ end
75
+
76
+ # Two things worth one line in the log at boot, because both are
77
+ # invisible until something is already wrong: a Rails/json pair that
78
+ # cannot decode, and an embedded dashboard with nothing declared in
79
+ # front of it.
80
+ initializer "railwatch.warnings", after: :load_config_initializers do
81
+ config.after_initialize do
82
+ next unless Railwatch.enabled?
83
+
84
+ Rails.logger.warn("[railwatch] #{Railwatch::JsonCompat.advice}") if Railwatch::JsonCompat.broken?
85
+
86
+ if Railwatch.config.local? && Railwatch.config.dashboard_gate == :undeclared && !Rails.env.local?
87
+ Rails.logger.warn(
88
+ "[railwatch] the dashboard at the engine's mount has no gate this gem can see: HTTP Basic is off and " \
89
+ "no base_controller_class, dashboard_user or dashboard_open is set. If a routes constraint or your " \
90
+ "network already gates it, set `c.dashboard_open = true` to say so (it also enables live updates); " \
91
+ "otherwise anyone who can reach the URL can read every query, log line and exception this app records."
92
+ )
93
+ end
94
+ end
95
+ end
96
+
21
97
  initializer "railwatch.transport_security", after: :load_config_initializers do
22
- next if Railwatch.config.ingest_url_allowed?
98
+ next if Railwatch.config.local? || Railwatch.config.ingest_url_allowed?
23
99
 
24
100
  Rails.logger.warn(
25
101
  "Railwatch will not send telemetry to #{Railwatch.config.ingest_url}: plain HTTP is allowed only for loopback " \
@@ -129,6 +205,17 @@ module Railwatch
129
205
  at_exit { Railwatch::Sessions.stop! }
130
206
  end
131
207
 
208
+ # The embedded install's maintenance clock (release health, scans,
209
+ # pruning). Same shape and ordering rationale as "railwatch.health":
210
+ # stopped before the reporter's final flush. Maintenance.start! is a
211
+ # no-op unless the transport is local.
212
+ initializer "railwatch.maintenance" do
213
+ next unless Railwatch.enabled?
214
+
215
+ Railwatch::Maintenance.start!
216
+ at_exit { Railwatch::Maintenance.stop! }
217
+ end
218
+
132
219
  # lib/tasks/railwatch_tasks.rake is picked up by Rails::Engine's default
133
220
  # lib/tasks convention; the rake_tasks block above only installs the
134
221
  # Rake::Task patch.
@@ -138,6 +225,7 @@ end
138
225
  require "railwatch/console"
139
226
  require "railwatch/health"
140
227
  require "railwatch/sessions"
228
+ require "railwatch/maintenance"
141
229
  require "railwatch/middleware/request"
142
230
  require "railwatch/job_tracing"
143
231
  require "railwatch/controller_helpers"
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The hosted platform bounds ingest request bodies with Rack middleware. In
4
+ # an embedded install nothing arrives over HTTP, but Ingest::Payload and
5
+ # Telemetry::Attachment still size their limits from this constant.
6
+ module Railwatch
7
+ module IngestRequestBodyLimit
8
+ MAX_BYTES = 32 * 1024 * 1024
9
+ end
10
+ end