railwatch 0.1.4 → 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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +205 -0
- data/README.md +10 -0
- data/app/channels/railwatch/environment_channel.rb +28 -0
- data/app/controllers/concerns/railwatch/telemetry_identity.rb +25 -0
- data/app/controllers/railwatch/alerts_controller.rb +48 -0
- data/app/controllers/railwatch/anomaly_rules_controller.rb +31 -0
- data/app/controllers/railwatch/attachments_controller.rb +22 -0
- data/app/controllers/railwatch/beacon_controller.rb +67 -7
- data/app/controllers/railwatch/broadcasts_controller.rb +15 -0
- data/app/controllers/railwatch/cache_events_controller.rb +30 -0
- data/app/controllers/railwatch/commands_controller.rb +16 -0
- data/app/controllers/railwatch/comments_controller.rb +11 -0
- data/app/controllers/railwatch/dashboard_controller.rb +69 -0
- data/app/controllers/railwatch/deploys_controller.rb +34 -0
- data/app/controllers/railwatch/deprecations_controller.rb +54 -0
- data/app/controllers/railwatch/environment_scoped.rb +99 -0
- data/app/controllers/railwatch/exceptions_controller.rb +53 -0
- data/app/controllers/railwatch/executions_controller.rb +13 -0
- data/app/controllers/railwatch/issues_controller.rb +258 -0
- data/app/controllers/railwatch/jobs_controller.rb +63 -0
- data/app/controllers/railwatch/llm_calls_controller.rb +124 -0
- data/app/controllers/railwatch/logs_controller.rb +40 -0
- data/app/controllers/railwatch/mails_controller.rb +19 -0
- data/app/controllers/railwatch/notifications_controller.rb +11 -0
- data/app/controllers/railwatch/outgoing_requests_controller.rb +19 -0
- data/app/controllers/railwatch/overview_controller.rb +38 -0
- data/app/controllers/railwatch/people_controller.rb +25 -0
- data/app/controllers/railwatch/processes_controller.rb +33 -0
- data/app/controllers/railwatch/profiles_controller.rb +50 -0
- data/app/controllers/railwatch/queries_controller.rb +59 -0
- data/app/controllers/railwatch/releases_controller.rb +75 -0
- data/app/controllers/railwatch/requests_controller.rb +55 -0
- data/app/controllers/railwatch/saved_views_controller.rb +53 -0
- data/app/controllers/railwatch/scheduled_tasks_controller.rb +45 -0
- data/app/controllers/railwatch/spans_controller.rb +47 -0
- data/app/controllers/railwatch/storage_ops_controller.rb +15 -0
- data/app/controllers/railwatch/tenants_controller.rb +44 -0
- data/app/controllers/railwatch/thresholds_controller.rb +40 -0
- data/app/controllers/railwatch/traces_controller.rb +72 -0
- data/app/controllers/railwatch/transactions_controller.rb +21 -0
- data/app/controllers/railwatch/view_renders_controller.rb +28 -0
- data/app/controllers/railwatch/visits_controller.rb +53 -0
- data/app/helpers/railwatch/assets_helper.rb +52 -0
- data/app/jobs/railwatch/anomaly_scan_job.rb +11 -0
- data/app/jobs/railwatch/application_job.rb +7 -0
- data/app/jobs/railwatch/auto_resolve_issues_job.rb +19 -0
- data/app/jobs/railwatch/check_scheduled_tasks_job.rb +113 -0
- data/app/jobs/railwatch/detect_anomalies_job.rb +171 -0
- data/app/jobs/railwatch/detect_performance_issues_job.rb +85 -0
- data/app/jobs/railwatch/group_exceptions_job.rb +91 -0
- data/app/jobs/railwatch/optimize_telemetry_job.rb +23 -0
- data/app/jobs/railwatch/performance_scan_job.rb +11 -0
- data/app/jobs/railwatch/prune_telemetry_job.rb +110 -0
- data/app/jobs/railwatch/release_health_rollup_job.rb +64 -0
- data/app/jobs/railwatch/rollup_catchup_job.rb +21 -0
- data/app/jobs/railwatch/rollup_job.rb +130 -0
- data/app/jobs/railwatch/scheduled_task_scan_job.rb +11 -0
- data/app/models/concerns/railwatch/detection_snapshotting.rb +20 -0
- data/app/models/railwatch/alert.rb +229 -0
- data/app/models/railwatch/alert_rule.rb +121 -0
- data/app/models/railwatch/anomaly_rule.rb +33 -0
- data/app/models/railwatch/application.rb +44 -0
- data/app/models/railwatch/application_record.rb +33 -0
- data/app/models/railwatch/comment.rb +36 -0
- data/app/models/railwatch/deploy.rb +67 -0
- data/app/models/railwatch/environment.rb +54 -0
- data/app/models/railwatch/execution_presenter.rb +185 -0
- data/app/models/railwatch/filter_query.rb +143 -0
- data/app/models/railwatch/followup_receipt.rb +27 -0
- data/app/models/railwatch/ingest/batch.rb +305 -0
- data/app/models/railwatch/ingest/mapper.rb +575 -0
- data/app/models/railwatch/ingest/payload.rb +96 -0
- data/app/models/railwatch/ingest/rollup_absorber.rb +170 -0
- data/app/models/railwatch/ingest/writer.rb +137 -0
- data/app/models/railwatch/issue.rb +277 -0
- data/app/models/railwatch/issue_activity.rb +23 -0
- data/app/models/railwatch/issue_detection_presenter.rb +309 -0
- data/app/models/railwatch/issue_detection_snapshot.rb +77 -0
- data/app/models/railwatch/maintenance_task.rb +53 -0
- data/app/models/railwatch/saved_view.rb +63 -0
- data/app/models/railwatch/telemetry/aggregations.rb +116 -0
- data/app/models/railwatch/telemetry/attachment.rb +31 -0
- data/app/models/railwatch/telemetry/bounded_gzip.rb +101 -0
- data/app/models/railwatch/telemetry/broadcast.rb +13 -0
- data/app/models/railwatch/telemetry/cache_event.rb +16 -0
- data/app/models/railwatch/telemetry/child.rb +53 -0
- data/app/models/railwatch/telemetry/cursor_page.rb +99 -0
- data/app/models/railwatch/telemetry/deprecation.rb +13 -0
- data/app/models/railwatch/telemetry/enqueued_job.rb +13 -0
- data/app/models/railwatch/telemetry/exception.rb +21 -0
- data/app/models/railwatch/telemetry/execution.rb +71 -0
- data/app/models/railwatch/telemetry/health_sample.rb +72 -0
- data/app/models/railwatch/telemetry/ingest_batch.rb +31 -0
- data/app/models/railwatch/telemetry/llm_call.rb +37 -0
- data/app/models/railwatch/telemetry/log.rb +85 -0
- data/app/models/railwatch/telemetry/mail.rb +13 -0
- data/app/models/railwatch/telemetry/n_plus_one.rb +92 -0
- data/app/models/railwatch/telemetry/notification.rb +13 -0
- data/app/models/railwatch/telemetry/outgoing_request.rb +13 -0
- data/app/models/railwatch/telemetry/person.rb +50 -0
- data/app/models/railwatch/telemetry/process.rb +10 -0
- data/app/models/railwatch/telemetry/profile.rb +37 -0
- data/app/models/railwatch/telemetry/query.rb +50 -0
- data/app/models/railwatch/telemetry/query_shape.rb +45 -0
- data/app/models/railwatch/telemetry/release_health.rb +63 -0
- data/app/models/railwatch/telemetry/rollup.rb +78 -0
- data/app/models/railwatch/telemetry/session.rb +24 -0
- data/app/models/railwatch/telemetry/span.rb +19 -0
- data/app/models/railwatch/telemetry/storage_op.rb +13 -0
- data/app/models/railwatch/telemetry/tenant.rb +221 -0
- data/app/models/railwatch/telemetry/transaction.rb +13 -0
- data/app/models/railwatch/telemetry/view_render.rb +13 -0
- data/app/models/railwatch/telemetry/visit.rb +24 -0
- data/app/models/railwatch/telemetry_record.rb +57 -0
- data/app/models/railwatch/threshold.rb +29 -0
- data/app/models/railwatch/user.rb +47 -0
- data/app/models/railwatch/viewer.rb +13 -0
- data/app/views/layouts/railwatch/dashboard.html.erb +25 -0
- data/config/routes.rb +59 -0
- data/db/railwatch_migrate/20260916000000_create_railwatch_tables.rb +151 -0
- data/db/railwatch_migrate/20260917000000_create_railwatch_maintenance_tasks.rb +18 -0
- data/db/railwatch_migrate/20260917120000_create_railwatch_followup_receipts.rb +20 -0
- data/db/railwatch_telemetry_migrate/20260903000001_create_telemetry.rb +481 -0
- data/db/railwatch_telemetry_migrate/20260903000002_rename_tenant_to_app_tenant.rb +14 -0
- data/db/railwatch_telemetry_migrate/20260903000003_add_statement_count_to_transactions.rb +9 -0
- data/db/railwatch_telemetry_migrate/20260903000004_add_role_and_channel.rb +10 -0
- data/db/railwatch_telemetry_migrate/20260903000005_add_locals_to_exceptions.rb +9 -0
- data/db/railwatch_telemetry_migrate/20260903000006_add_spans_health_vitals_and_fts.rb +82 -0
- data/db/railwatch_telemetry_migrate/20260903000007_rename_span_attributes_to_payload.rb +10 -0
- data/db/railwatch_telemetry_migrate/20260903000008_add_profiles_and_attachments.rb +60 -0
- data/db/railwatch_telemetry_migrate/20260903000009_add_truncated_to_attachments.rb +9 -0
- data/db/railwatch_telemetry_migrate/20260903000010_create_sessions_and_release_health.rb +51 -0
- data/db/railwatch_telemetry_migrate/20260903000011_add_fingerprint_to_exceptions.rb +11 -0
- data/db/railwatch_telemetry_migrate/20260904000012_add_failed_to_broadcasts.rb +10 -0
- data/db/railwatch_telemetry_migrate/20260904010000_add_filter_cursor_indexes.rb +37 -0
- data/db/railwatch_telemetry_migrate/20260904020000_add_n_plus_ones_execution_id_index.rb +11 -0
- data/db/railwatch_telemetry_migrate/20260904120000_create_query_shapes.rb +15 -0
- data/db/railwatch_telemetry_migrate/20260906120000_add_backpressure_factor_to_ingest_batches.rb +9 -0
- data/db/railwatch_telemetry_migrate/20260907000000_rename_lantern_version_on_processes.rb +10 -0
- data/db/railwatch_telemetry_migrate/20260913000000_rename_nightrail_version_on_processes.rb +9 -0
- data/db/railwatch_telemetry_migrate/20260914000000_drop_orphan_durable_ingest_tables.rb +18 -0
- data/db/railwatch_telemetry_migrate/20260914010000_drop_orphan_durable_ingest_columns.rb +25 -0
- data/db/railwatch_telemetry_migrate/20260915000000_create_llm_calls.rb +55 -0
- data/db/railwatch_telemetry_migrate/20260915120000_add_detail_to_llm_calls.rb +21 -0
- data/db/railwatch_telemetry_migrate/20260915200000_add_explained_index_to_queries.rb +16 -0
- data/db/railwatch_telemetry_migrate/20260915210000_add_slowest_index_to_queries.rb +18 -0
- data/db/railwatch_telemetry_migrate/20260917010000_add_batch_ledger_to_ingest_batches.rb +16 -0
- data/docs/configuration.md +26 -0
- data/docs/embedded.md +342 -0
- data/docs/getting-started.md +5 -0
- data/lib/generators/railwatch/install/install_generator.rb +222 -1
- data/lib/generators/railwatch/install/templates/{initializer.rb → initializer.rb.tt} +39 -0
- data/lib/generators/railwatch/install/templates/post-deploy +8 -0
- data/lib/puma/plugin/railwatch.rb +170 -0
- data/lib/railwatch/authentication.rb +83 -0
- data/lib/railwatch/configuration.rb +134 -3
- data/lib/railwatch/dashboard_assets.rb +45 -0
- data/lib/railwatch/embedded.rb +51 -0
- data/lib/railwatch/engine.rb +89 -1
- data/lib/railwatch/ingest_request_body_limit.rb +10 -0
- data/lib/railwatch/json_compat.rb +60 -0
- data/lib/railwatch/maintenance.rb +183 -0
- data/lib/railwatch/patches/runner_command.rb +21 -1
- data/lib/railwatch/record.rb +33 -8
- data/lib/railwatch/reporter.rb +16 -0
- data/lib/railwatch/subscribers/process_info.rb +2 -1
- data/lib/railwatch/transport/local.rb +78 -0
- data/lib/railwatch/transport/socket.rb +183 -0
- data/lib/railwatch/version.rb +1 -1
- data/lib/railwatch/writer.rb +370 -0
- data/lib/railwatch.rb +38 -1
- data/lib/tasks/railwatch_tasks.rake +128 -0
- data/public/railwatch/assets/CommitMono-Bold-D6h61ieg.woff2 +0 -0
- data/public/railwatch/assets/CommitMono-Regular-zr8w7Obm.woff2 +0 -0
- data/public/railwatch/assets/Roboto-Black-auA4GeOK.woff2 +0 -0
- data/public/railwatch/assets/Roboto-Bold-CJLnO8j1.woff2 +0 -0
- data/public/railwatch/assets/Roboto-Medium-Cm2bwKpj.woff2 +0 -0
- data/public/railwatch/assets/Roboto-Regular-Chaq1-PV.woff2 +0 -0
- data/public/railwatch/assets/app-layout-yh-sPWgK.js +1 -0
- data/public/railwatch/assets/app-wordmark-nbjkzxwQ.js +1 -0
- data/public/railwatch/assets/appearance-CDuRvQTB.js +1 -0
- data/public/railwatch/assets/application-C_kpBdnf.css +1 -0
- data/public/railwatch/assets/arrow-up-DVtOGdVA.js +1 -0
- data/public/railwatch/assets/auth-layout-TCwPpS1K.js +1 -0
- data/public/railwatch/assets/badge-Daw4Hvr8.js +1 -0
- data/public/railwatch/assets/braces-DhFbHPsz.js +1 -0
- data/public/railwatch/assets/card-BX_3HXcJ.js +1 -0
- data/public/railwatch/assets/chart-B14-N9g7.js +39 -0
- data/public/railwatch/assets/chart-hover-Vy52H4uD.js +1 -0
- data/public/railwatch/assets/chart-panel-Cb4S_ej_.js +1 -0
- data/public/railwatch/assets/checkbox-D3aRSsBj.js +1 -0
- data/public/railwatch/assets/code-KvW8k7Jr.js +1 -0
- data/public/railwatch/assets/copy-DaQMJWoT.js +1 -0
- data/public/railwatch/assets/copy-block-BKFGd11J.js +1 -0
- data/public/railwatch/assets/copy-id-Djks1fXB.js +1 -0
- data/public/railwatch/assets/cursor-load-more-BXV0f1D_.js +1 -0
- data/public/railwatch/assets/data-table-iv1bdF6u.js +1 -0
- data/public/railwatch/assets/edit-BZc_Iawe.js +1 -0
- data/public/railwatch/assets/edit-DMKUF8Zi.js +8 -0
- data/public/railwatch/assets/edit-__9yJlO3.js +1 -0
- data/public/railwatch/assets/empty-state-6j_0AaQQ.js +1 -0
- data/public/railwatch/assets/env-layout-DrT8rO6P.js +1 -0
- data/public/railwatch/assets/execution-path-CzgBUi5e.js +1 -0
- data/public/railwatch/assets/filter-bar-9SU5NrzX.js +1 -0
- data/public/railwatch/assets/flamegraph-qcekju8V.js +2 -0
- data/public/railwatch/assets/format-B9SDkrWj.js +1 -0
- data/public/railwatch/assets/frames-Cyu7KMxZ.js +1 -0
- data/public/railwatch/assets/google-sign-in-button-DsTSfmzY.js +1 -0
- data/public/railwatch/assets/index-1ol1-QWI.js +1 -0
- data/public/railwatch/assets/index-5jI4aFzC.js +1 -0
- data/public/railwatch/assets/index-9KTrVnrc.js +1 -0
- data/public/railwatch/assets/index-B0-8lcTp.js +1 -0
- data/public/railwatch/assets/index-B7jjfNfO.js +1 -0
- data/public/railwatch/assets/index-BBchRy0M.js +1 -0
- data/public/railwatch/assets/index-BRiq3SNR.js +1 -0
- data/public/railwatch/assets/index-BgKj9xhr.js +1 -0
- data/public/railwatch/assets/index-BkTZqqOu.js +1 -0
- data/public/railwatch/assets/index-BprKx8QO.js +1 -0
- data/public/railwatch/assets/index-C3jzvPs3.js +1 -0
- data/public/railwatch/assets/index-Cbs6gGyQ.js +1 -0
- data/public/railwatch/assets/index-CdRZ6AWF.js +1 -0
- data/public/railwatch/assets/index-CeYKnapu.js +1 -0
- data/public/railwatch/assets/index-Cmlwy1-V.js +1 -0
- data/public/railwatch/assets/index-Cwx6058d.js +1 -0
- data/public/railwatch/assets/index-D4CSdbHv.js +1 -0
- data/public/railwatch/assets/index-DEFMSkdG.js +1 -0
- data/public/railwatch/assets/index-DFiHEBSh.js +1 -0
- data/public/railwatch/assets/index-DL4vWdWJ.js +1 -0
- data/public/railwatch/assets/index-DU9F5b5d.js +1 -0
- data/public/railwatch/assets/index-DaXgPcGL.js +1 -0
- data/public/railwatch/assets/index-DbtaU-EE.js +1 -0
- data/public/railwatch/assets/index-DeOe83F4.js +1 -0
- data/public/railwatch/assets/index-DiucHN4B.js +1 -0
- data/public/railwatch/assets/index-DlnR_l9o.js +1 -0
- data/public/railwatch/assets/index-DlumCsWY.js +2 -0
- data/public/railwatch/assets/index-DmRd7aIG.js +1 -0
- data/public/railwatch/assets/index-DxSh2UpM.js +1 -0
- data/public/railwatch/assets/index-MIMGuFNt.js +1 -0
- data/public/railwatch/assets/index-OqI59zPb.js +1 -0
- data/public/railwatch/assets/index-P4rC7IlX.js +1 -0
- data/public/railwatch/assets/index-gpPOcFWq.js +1 -0
- data/public/railwatch/assets/index-oVkururr.js +1 -0
- data/public/railwatch/assets/index-p9puqVge.js +1 -0
- data/public/railwatch/assets/inertia-TViv6kNv.js +97 -0
- data/public/railwatch/assets/input-error-LxImUkxv.js +1 -0
- data/public/railwatch/assets/json-viewer-Ar4cjPDW.js +1 -0
- data/public/railwatch/assets/klass-CJ-J4INB.js +1 -0
- data/public/railwatch/assets/label-COUKWqE_.js +1 -0
- data/public/railwatch/assets/layout-DNSLAkw_.js +1 -0
- data/public/railwatch/assets/live-dot-ChfUtY3p.js +41 -0
- data/public/railwatch/assets/nav-CNnDqPlm.js +1 -0
- data/public/railwatch/assets/new-84S8ZJq9.js +1 -0
- data/public/railwatch/assets/new-Be55nmt9.js +1 -0
- data/public/railwatch/assets/new-Bi_xQiIb.js +1 -0
- data/public/railwatch/assets/new-BvCT8TMg.js +1 -0
- data/public/railwatch/assets/new-D05SajFR.js +1 -0
- data/public/railwatch/assets/new-D4uewYC8.js +1 -0
- data/public/railwatch/assets/onboarding-CYZi5Cqc.js +1 -0
- data/public/railwatch/assets/origin-identity-6q1-CBts.js +1 -0
- data/public/railwatch/assets/percentile-picker-gFZCXtdb.js +1 -0
- data/public/railwatch/assets/relative-time-IOOgl5n2.js +1 -0
- data/public/railwatch/assets/release-health-DC8oc7uw.js +1 -0
- data/public/railwatch/assets/route-Dv6LAWvT.js +1 -0
- data/public/railwatch/assets/segmented-h1VdDTqE.js +1 -0
- data/public/railwatch/assets/select-_AJsUa7X.js +1 -0
- data/public/railwatch/assets/separator-BwwTYtCF.js +1 -0
- data/public/railwatch/assets/series-chart-DaFPefku.js +1 -0
- data/public/railwatch/assets/show-B7NCgkEo.js +1 -0
- data/public/railwatch/assets/show-BKqyKjBK.js +1 -0
- data/public/railwatch/assets/show-BM6X2Mpo.js +1 -0
- data/public/railwatch/assets/show-BNw4tN5q.js +1 -0
- data/public/railwatch/assets/show-BO3bnG5h.js +1 -0
- data/public/railwatch/assets/show-BhrAVAEA.js +1 -0
- data/public/railwatch/assets/show-C4Ltf5i9.js +2 -0
- data/public/railwatch/assets/show-C8sHalnw.js +1 -0
- data/public/railwatch/assets/show-CeTL4B37.js +2 -0
- data/public/railwatch/assets/show-CpfgV1jP.js +1 -0
- data/public/railwatch/assets/show-DACku6AD.js +3 -0
- data/public/railwatch/assets/show-DIOSGcXV.js +6 -0
- data/public/railwatch/assets/show-DQp_1n-B.js +1 -0
- data/public/railwatch/assets/show-DVNz46RI.js +1 -0
- data/public/railwatch/assets/show-DYteoYWW.js +1 -0
- data/public/railwatch/assets/show-DgSIoRvA.js +1 -0
- data/public/railwatch/assets/show-JxFtB4eK.js +2 -0
- data/public/railwatch/assets/sort-header-DpFzXblu.js +1 -0
- data/public/railwatch/assets/source-link-B2183i2-.js +1 -0
- data/public/railwatch/assets/sparkline-cell-C3-5vFkP.js +1 -0
- data/public/railwatch/assets/stat-s4RpOS9w.js +1 -0
- data/public/railwatch/assets/status-badge-8jVV-LA4.js +1 -0
- data/public/railwatch/assets/tenant-path-G-6u9A-o.js +1 -0
- data/public/railwatch/assets/text-link-DfsiaCcP.js +1 -0
- data/public/railwatch/assets/textarea-Dye72uP7.js +1 -0
- data/public/railwatch/assets/timeline-CD7WHnbo.js +1 -0
- data/public/railwatch/assets/transition-B_AW8rMK.js +5 -0
- data/public/railwatch/assets/use-clipboard-ColgLyQ2.js +1 -0
- data/public/railwatch/icon.png +0 -0
- data/public/railwatch/icon.svg +5 -0
- data/public/railwatch/manifest.json +2171 -0
- data/public/railwatch/rails-vite.json +1 -0
- metadata +313 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9500835353b9f428ff09ae908f9cbc360c44f0ea01740139738ec8c06ce73eb2
|
|
4
|
+
data.tar.gz: 7b4e2b48a881ef09243af6d2f51865609bd5615e55d27d9fca784f3e2740de7b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 0d38ab1fced44ae3168de8f7864b858732ad07a26a4a9a96d82b74891d3b20144b9cc19a703743a127a2d270552ba422e9d51c1c9f8e768077c5a12364f613c8
|
|
7
|
+
data.tar.gz: 0e4ad6b17f76856cc2f160c64c992ed8405e50ba9485563c6e3b40495fdb11c1da756d8aa0317adec1d78b93f4b3dc6783de196c33c42d58dbbc606e3491310f
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,210 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## Unreleased
|
|
4
|
+
|
|
5
|
+
- Embedded mode: `bin/rails generate railwatch:install --local` keeps
|
|
6
|
+
every record in two SQLite databases the app owns (`railwatch` for
|
|
7
|
+
issues, comments, saved views, thresholds and deploys;
|
|
8
|
+
`railwatch_telemetry` for what the app reports) and serves the whole
|
|
9
|
+
Railwatch dashboard at `/railwatch`, from a bundle shipped inside the
|
|
10
|
+
gem. No token, no Node, no asset pipeline. `c.transport = :local`
|
|
11
|
+
(`RAILWATCH_TRANSPORT=local`) switches the reporter to write batches
|
|
12
|
+
in-process; everything else about sampling, redaction and buffering is
|
|
13
|
+
unchanged. The installer adds the databases (migrated from the gem's own
|
|
14
|
+
migration history, so a gem update is followed by `db:prepare` and
|
|
15
|
+
nothing else) and an
|
|
16
|
+
initializer with `issue_prefix`, `repository_url`, `retention_days`
|
|
17
|
+
and a `dashboard_user` resolver. `railwatch:doctor`, `railwatch:status`
|
|
18
|
+
and `railwatch:deploy` understand the mode. See docs/embedded.md.
|
|
19
|
+
- Embedded mode needs no job worker. Exceptions are grouped into issues
|
|
20
|
+
as each batch lands, and release health, threshold and anomaly scans,
|
|
21
|
+
missed scheduled tasks, auto-resolve and pruning run from
|
|
22
|
+
`Railwatch::Maintenance`, a clocked thread in every web and worker
|
|
23
|
+
process (one process per server runs each task, leased through the new
|
|
24
|
+
`railwatch_maintenance_tasks` table). Nothing goes through Active Job,
|
|
25
|
+
so the gem never writes the host's queue adapter, and a dead worker
|
|
26
|
+
cannot hide its own missed runs. The installer no longer edits
|
|
27
|
+
`config/recurring.yml`; remove any `Railwatch::*` entries a pre-release
|
|
28
|
+
added there.
|
|
29
|
+
- Embedded ingest folds each batch into the hourly rollups as it lands
|
|
30
|
+
(`Ingest::RollupAbsorber`), so the dashboard's counts and percentiles
|
|
31
|
+
move with every batch; the hosted platform's per-batch RollupJob
|
|
32
|
+
recompute is not enqueued in embedded mode and the minute-long
|
|
33
|
+
aggregate cache is bypassed there.
|
|
34
|
+
- Embedded mode gets a writer process. `plugin :railwatch` in
|
|
35
|
+
`config/puma.rb` (added by `--local`) forks one `Railwatch::Writer`
|
|
36
|
+
from the Puma master, the way Solid Queue's in-Puma mode does. Web
|
|
37
|
+
workers hand their batches to it over a Unix socket
|
|
38
|
+
(`Transport::Socket`) instead of writing SQLite on their own reporter
|
|
39
|
+
thread, so mapping, the write lock, rollups, issue grouping and the
|
|
40
|
+
maintenance clock all run in a process whose interpreter no request
|
|
41
|
+
shares. The writer is restarted by Puma if it dies and stops with it;
|
|
42
|
+
a process with no writer (a runner, a Solid Queue worker, a server
|
|
43
|
+
without the plugin) writes in-process from its first miss. The doctor
|
|
44
|
+
reports whether the writer is listening. Measured
|
|
45
|
+
on a two-worker Puma host under five minutes of open-loop load with
|
|
46
|
+
every record sampled: all-paths p95 817 ms with the writer against
|
|
47
|
+
938 ms writing on the worker threads and 716 ms with Railwatch off, and
|
|
48
|
+
each worker's reporter thread fell from 7 s of CPU per minute of load
|
|
49
|
+
to 2 s over the whole run.
|
|
50
|
+
- Hardening from an adversarial review of the writer: a batch's
|
|
51
|
+
exceptions are counted onto an issue exactly once however many times
|
|
52
|
+
its follow-ups run (a `railwatch_followup_receipts` row per batch and
|
|
53
|
+
group, committed with the count); the wedge guard tracks each write by
|
|
54
|
+
invocation so a retry of the same batch cannot hide a stuck original;
|
|
55
|
+
the writer reads and writes under deadlines, bounds inflation and the
|
|
56
|
+
accept queue, binds inside a 0700 directory as a 0600 socket, and
|
|
57
|
+
refuses to take over a socket another writer is answering on; Puma
|
|
58
|
+
phased restarts restart the writer rather than losing it; workers
|
|
59
|
+
under the plugin retain batches while the writer is away, and any
|
|
60
|
+
other process falls back to in-process writing on the first miss
|
|
61
|
+
(including a stale socket file); a maintenance lease is released only
|
|
62
|
+
by the token that claimed it and a failed task is retried next tick;
|
|
63
|
+
maintenance failures report through `on_unrecoverable`, never
|
|
64
|
+
`Rails.error`; pruning is bounded per run. The plugin is cluster-mode
|
|
65
|
+
only.
|
|
66
|
+
- Embedded ingest keeps a delivery ledger. The reporter's batch id is
|
|
67
|
+
stored on the `ingest_batches` row inside the batch's own transaction,
|
|
68
|
+
so a batch the reporter retries after a failure is written once, and
|
|
69
|
+
a write that fails is now retried with the same backoff the HTTP
|
|
70
|
+
transport gets instead of being dropped. The exception grouping a
|
|
71
|
+
batch owes is recorded on that row as well and cleared when done; a
|
|
72
|
+
process that dies in between leaves it for `Railwatch::Maintenance` to
|
|
73
|
+
finish on its next tick, so an exception can no longer be stored
|
|
74
|
+
without ever becoming an issue.
|
|
75
|
+
- A missed scheduled task says why, when Solid Queue is the adapter:
|
|
76
|
+
the scheduler never enqueued the run, it was enqueued but no worker
|
|
77
|
+
is running, or it is enqueued and waiting behind a backlog. Read from
|
|
78
|
+
Solid Queue's own `recurring_executions` and `processes` tables.
|
|
79
|
+
- `rails runner script/x.rb` is a deployed script even when the whole
|
|
80
|
+
application is checked out under a scratch directory such as `/tmp`;
|
|
81
|
+
only a scratch path inside the app still counts as interactive.
|
|
82
|
+
- `RollupJob` no longer overwrites a rollup that a batch folded into
|
|
83
|
+
between its read and its write: a stored count higher than the
|
|
84
|
+
recomputed one is kept, and the next run picks the group up.
|
|
85
|
+
- `PruneTelemetryJob` takes `checkpoint:`; the embedded clock prunes with
|
|
86
|
+
a PASSIVE WAL checkpoint so it never blocks the app's own readers.
|
|
87
|
+
- The affected-user count on an issue is recomputed at most once every
|
|
88
|
+
five minutes per issue (and always for a new one) instead of on every
|
|
89
|
+
batch that touched the group.
|
|
90
|
+
- The gem now depends on `inertia_rails` and `tdigest` for the dashboard.
|
|
91
|
+
- Embedded install is one command and boots in production. `bin/rails
|
|
92
|
+
generate railwatch:install --local` now creates and migrates both
|
|
93
|
+
databases itself (no separate `db:prepare`), fills in the production
|
|
94
|
+
`database:` paths Rails 8.1's template leaves commented out. The two
|
|
95
|
+
entries name `adapter: sqlite3` themselves instead of inheriting the
|
|
96
|
+
app's default, so embedded mode works on a PostgreSQL or MySQL app (the
|
|
97
|
+
generator adds `gem "sqlite3"` there and asks for a `bundle install`
|
|
98
|
+
first) and needs no `&default` anchor to exist. The gem
|
|
99
|
+
depends on `json < 3` for now: Rails 8.1 cannot decode with json 3
|
|
100
|
+
(rails/rails#58784), which broke this gem's own SQLite migrations on a
|
|
101
|
+
fresh Ruby 3.4.10, and a dependency is what makes `bundle add
|
|
102
|
+
railwatch` resolve past it. The engine loads Active Job itself
|
|
103
|
+
and treats Action Cable as optional, so an app from `rails new
|
|
104
|
+
--minimal` boots with it.
|
|
105
|
+
- The engine's models never fall back to the host's primary database. Both
|
|
106
|
+
abstract bases rescue a missing `database.yml` entry so a cloud-transport
|
|
107
|
+
app still boots and eager-loads them, but using one then raises
|
|
108
|
+
`Railwatch::DatabaseNotConfigured` instead of inheriting
|
|
109
|
+
`ActiveRecord::Base`'s connection -- where the unprefixed telemetry
|
|
110
|
+
tables (`sessions`, `visits`, `people`, `notifications`) are the
|
|
111
|
+
application's own.
|
|
112
|
+
- The in-process fallback is provisional: a process that found no writer
|
|
113
|
+
re-checks the socket every 30 seconds instead of writing its own batches
|
|
114
|
+
for the rest of its life, and a process that expects a writer stops
|
|
115
|
+
retaining and writes its own after a minute without one, rather than
|
|
116
|
+
holding batches until the retry cap drops them. A batch abandoned after the retry cap is now
|
|
117
|
+
reported through `on_unrecoverable` rather than only under
|
|
118
|
+
`RAILWATCH_DEBUG`, and `railwatch:doctor` no longer calls a configured
|
|
119
|
+
but absent writer a pass.
|
|
120
|
+
- The generated Puma line is `plugin :railwatch`, unconditional:
|
|
121
|
+
`bundle exec puma` evaluates `config/puma.rb` before it loads the app,
|
|
122
|
+
so the old `if defined?(Railwatch)` guard meant a writer was never
|
|
123
|
+
started there. The plugin itself now decides whether to run after boot.
|
|
124
|
+
- Embedded mode is verified on PostgreSQL and MySQL hosts, not only
|
|
125
|
+
SQLite ones. The telemetry databases are SQLite files whatever the
|
|
126
|
+
application runs on, so the install is `generate`, `bundle install`
|
|
127
|
+
(for the sqlite3 gem the generator adds), then `db:prepare`. On both,
|
|
128
|
+
the app's own databases keep every table they had and the server gains
|
|
129
|
+
no Railwatch table at all.
|
|
130
|
+
- Releasing is gated on the thing that silently breaks it. `gem build`
|
|
131
|
+
globs its file list, so a missing or half-built dashboard produces a
|
|
132
|
+
gem that installs cleanly and serves a blank page; `rake
|
|
133
|
+
package:assert_dashboard` refuses to publish one, and the release
|
|
134
|
+
workflow runs it between building the bundle and building the gem. The
|
|
135
|
+
publish job also installs the bundle it was already calling `bundle
|
|
136
|
+
exec` against, which it had never done. Built `.gem` files are no
|
|
137
|
+
longer tracked in git.
|
|
138
|
+
- The browser beacon is bounded the way a hosted product bounds a public
|
|
139
|
+
ingest endpoint, since one cannot hold a credential the page does not
|
|
140
|
+
already give away: an origin allowlist (`beacon_allowed_origins`,
|
|
141
|
+
same-origin by default, the analogue of Sentry's allowed domains), the
|
|
142
|
+
per-client rate limit, and a new ceiling for the endpoint as a whole
|
|
143
|
+
(`beacon_global_rate_limit`, 6,000/minute) so a rotating address cannot
|
|
144
|
+
multiply past the first. Both limits fail closed on a cache store that
|
|
145
|
+
cannot count, where the limiter used to fail open.
|
|
146
|
+
- How the dashboard is gated is now something the app states rather than
|
|
147
|
+
something the gem guesses. `Configuration#dashboard_gate` names it:
|
|
148
|
+
HTTP Basic, a `base_controller_class`, a `dashboard_user` resolver that
|
|
149
|
+
can refuse, or `dashboard_open = true` for a dashboard that is public on
|
|
150
|
+
purpose (a private network, a VPN, a routes constraint the gem cannot
|
|
151
|
+
see). With Basic off and none of them set the gate is undeclared: the
|
|
152
|
+
dashboard still serves, because a constraint is a legitimate answer, but
|
|
153
|
+
the app logs a warning at every boot outside development, the doctor
|
|
154
|
+
reports it, and live updates are refused. The live channel follows the
|
|
155
|
+
declared gate, which matters because Action Cable runs on the host's own
|
|
156
|
+
`/cable` endpoint that no routes constraint around the mount covers.
|
|
157
|
+
- Railwatch no longer pins `json` for the host. The Rails 8.1 and json 3
|
|
158
|
+
incompatibility (rails/rails#58784) is the application's own, and a
|
|
159
|
+
gemspec dependency would constrain every bundle for it. The gem detects
|
|
160
|
+
the pair by asking it to decode rather than by comparing version
|
|
161
|
+
numbers, so a patched Rails or a backport is judged correctly and the
|
|
162
|
+
warning goes quiet by itself when Rails ships the fix. The installer
|
|
163
|
+
offers the pin in the app's Gemfile, the doctor reports it, and the app
|
|
164
|
+
logs it once at boot.
|
|
165
|
+
- Both database bases also survive an entry whose adapter gem is not in
|
|
166
|
+
the bundle yet (a `LoadError` rather than `AdapterNotSpecified`), which
|
|
167
|
+
is the state a PostgreSQL app is in between the installer adding
|
|
168
|
+
`gem "sqlite3"` and the `bundle install` that follows.
|
|
169
|
+
- The embedded dashboard authenticates the way Mission Control Jobs
|
|
170
|
+
does: HTTP Basic is on and closed by default, so with no credentials
|
|
171
|
+
every dashboard page answers 401 (with a note saying what to run), the
|
|
172
|
+
live channel refuses the subscription, and the doctor reports it. The
|
|
173
|
+
beacon endpoint and the dashboard's own static assets stay public, as
|
|
174
|
+
they must be. `bin/rails
|
|
175
|
+
railwatch:authentication:configure` writes
|
|
176
|
+
`railwatch.http_basic_auth_user/_password` to the environment's Rails
|
|
177
|
+
credentials; `RAILWATCH_HTTP_BASIC_AUTH_USER/_PASSWORD` or the
|
|
178
|
+
initializer do the same. A host with its own admin auth sets
|
|
179
|
+
`c.http_basic_auth_enabled = false` and either
|
|
180
|
+
`c.base_controller_class` (the dashboard controllers inherit from it)
|
|
181
|
+
or a routes constraint around the mount. Before this a production
|
|
182
|
+
install served every query and log line to anyone who found the URL.
|
|
183
|
+
- The Puma plugin forks the writer in single mode too (the default for a
|
|
184
|
+
Rails 8 app). It stops the writer from `at_exit`, after Puma's run loop
|
|
185
|
+
has returned: Puma's SIGTERM trap fires `after_stopped` BEFORE it drains
|
|
186
|
+
in-flight requests, so stopping there took the writer away from requests
|
|
187
|
+
that were still running and lost their records. The live-update
|
|
188
|
+
broadcast after a batch rescues a `LoadError` as well: a host whose
|
|
189
|
+
production `cable.yml` names redis without the gem (Rails 8.1's
|
|
190
|
+
non-Docker template) used to take the writer down on every batch.
|
|
191
|
+
- Puma workers under the plugin actually use the writer. The socket
|
|
192
|
+
transport captured "is a writer expected" when it was built, and
|
|
193
|
+
`rails server` builds the reporter (app boot) before Puma evaluates
|
|
194
|
+
`config/puma.rb` (where the plugin sets the flag), so every worker
|
|
195
|
+
inherited a transport that expected no writer, missed the socket once
|
|
196
|
+
during the writer's startup, and wrote its own batches in-process for
|
|
197
|
+
the rest of its life. The flag is now read at delivery time. On the
|
|
198
|
+
dogfood host this is the difference between a reporter thread at 3.6 ms
|
|
199
|
+
of CPU per request in each worker and one at 0.2 ms, with the writer
|
|
200
|
+
process doing the 2.8 ms.
|
|
201
|
+
- The per-record memory estimate on the request thread
|
|
202
|
+
(`Record.buffered_bytes`, run once for every query, cache event and log
|
|
203
|
+
line an execution buffers) walks a record in one loop instead of one
|
|
204
|
+
method call per value: 6.2 to 2.7 us for a query record, about 120 us
|
|
205
|
+
off a 20-query request. Same numbers for every record shape, including
|
|
206
|
+
at the depth bound and on a self-referential one.
|
|
207
|
+
|
|
3
208
|
## 0.1.4 (2026-09-15)
|
|
4
209
|
|
|
5
210
|
- `llm_call` records what the call carried and how it was configured, not
|
data/README.md
CHANGED
|
@@ -15,6 +15,14 @@ bin/rails generate railwatch:install --prompt-token # 2. hidden token input plu
|
|
|
15
15
|
bin/rails railwatch:doctor # 3. check every piece is wired up after restart
|
|
16
16
|
```
|
|
17
17
|
|
|
18
|
+
Or keep everything inside your app, with the full dashboard at
|
|
19
|
+
`/railwatch` and no token ([Embedded mode](docs/embedded.md)):
|
|
20
|
+
|
|
21
|
+
```sh
|
|
22
|
+
bundle add railwatch
|
|
23
|
+
bin/rails generate railwatch:install --local
|
|
24
|
+
```
|
|
25
|
+
|
|
18
26
|
Getting the token, the generator's flags, and deploying with Kamal,
|
|
19
27
|
Docker, Heroku, or Render are covered in
|
|
20
28
|
[Getting started](docs/getting-started.md). For a self-hosted deployment
|
|
@@ -76,6 +84,8 @@ expect { get "/widgets" }.not_to have_railwatch_n_plus_one
|
|
|
76
84
|
option by option and call site by call site.
|
|
77
85
|
- [Coming from Laravel Nightwatch](docs/replacing-nightwatch.md) — the
|
|
78
86
|
record-type mapping and the sampling model, for Laravel people.
|
|
87
|
+
- [Embedded mode](docs/embedded.md) — the whole dashboard inside your
|
|
88
|
+
app, telemetry in your own SQLite files, no cloud.
|
|
79
89
|
- [Self-hosting](docs/self-hosting.md) — pointing the gem at your own
|
|
80
90
|
Railwatch Cloud.
|
|
81
91
|
- [Troubleshooting](docs/troubleshooting.md) — every failure mode, paired
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Railwatch
|
|
4
|
+
# Live dashboard updates: Ingest::Batch broadcasts "ingested" on
|
|
5
|
+
# environment_<id> after each write (throttled to one per 2 s), and the
|
|
6
|
+
# page reloads its props. Under HTTP Basic the browser sends the same
|
|
7
|
+
# Authorization header on the WebSocket handshake, so the channel checks
|
|
8
|
+
# the same credentials as the pages.
|
|
9
|
+
#
|
|
10
|
+
# With Basic off, Configuration#dashboard_channel_allowed? asks whichever
|
|
11
|
+
# gate the host declared: a dashboard_user resolver can refuse this
|
|
12
|
+
# request, a named base controller or an explicit dashboard_open is taken
|
|
13
|
+
# at its word, and an undeclared gate refuses. That last case matters
|
|
14
|
+
# because Action Cable runs on the host's own /cable endpoint: a routes
|
|
15
|
+
# constraint around the engine's mount does not cover it and a base
|
|
16
|
+
# controller cannot reach it, so "something in front of /railwatch" is not
|
|
17
|
+
# evidence about this. What a subscriber would see is the ingest ping (a
|
|
18
|
+
# timestamp and per-type counts), never telemetry records.
|
|
19
|
+
class EnvironmentChannel < ActionCable::Channel::Base
|
|
20
|
+
def subscribed
|
|
21
|
+
if params[:id].to_i == Environment::ID && Railwatch.config.dashboard_channel_allowed?(connection.request)
|
|
22
|
+
stream_from "environment_#{Environment::ID}"
|
|
23
|
+
else
|
|
24
|
+
reject
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Builds identity props from the current environment's telemetry database.
|
|
4
|
+
# Callers must already be inside `telemetry { }`; this keeps Person lookups
|
|
5
|
+
# batched and prevents a reference from ever being resolved in another
|
|
6
|
+
# environment's tenant database.
|
|
7
|
+
module Railwatch
|
|
8
|
+
module TelemetryIdentity
|
|
9
|
+
private
|
|
10
|
+
|
|
11
|
+
def origin_people(rows)
|
|
12
|
+
refs = rows.filter_map(&:user_ref).uniq
|
|
13
|
+
refs.empty? ? {} : Telemetry::Person.where(ref: refs).index_by(&:ref)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def origin_identity(record, people)
|
|
17
|
+
person = people[record.user_ref]
|
|
18
|
+
{
|
|
19
|
+
user_ref: record.user_ref,
|
|
20
|
+
tenant: record.app_tenant,
|
|
21
|
+
person: person && { ref: person.ref, name: person.display_name }
|
|
22
|
+
}
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Fired-alert history. Reachable two ways: account-wide at /alerts (every
|
|
4
|
+
# environment) and, nested under an environment, scoped to that
|
|
5
|
+
# environment's issues. AlertRule (and so Alert) belongs to an application
|
|
6
|
+
# rather than an environment, so the env-scoped view filters through the
|
|
7
|
+
# alert's issue instead -- app-level alerts with no issue (e.g. quota) only
|
|
8
|
+
# show up in the account-wide view.
|
|
9
|
+
module Railwatch
|
|
10
|
+
class AlertsController < DashboardController
|
|
11
|
+
# One environment in an embedded install, so the account-wide and the
|
|
12
|
+
# environment-scoped listing are the same page; the base class already
|
|
13
|
+
# set the environment and shares its props.
|
|
14
|
+
|
|
15
|
+
def index
|
|
16
|
+
scope = account_alerts
|
|
17
|
+
scope = scope.joins(:issue).where(railwatch_issues: { environment_id: environment.id }) if params[:environment_id]
|
|
18
|
+
fields = FilterQuery.parse(params[:q]).fetch(:fields)
|
|
19
|
+
scope = scope.where(event: fields["event"]) if AlertRule::EVENTS.include?(fields["event"].to_s)
|
|
20
|
+
scope = scope.where(status: fields["status"]) if Alert::STATUSES.include?(fields["status"].to_s)
|
|
21
|
+
alerts = scope.order(created_at: :desc).limit(200)
|
|
22
|
+
render inertia: { alerts: alerts.map { |a| row(a) }, q: params[:q].to_s, statuses: Alert::STATUSES }
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def retry
|
|
26
|
+
alert = account_alerts.find(params[:id])
|
|
27
|
+
if alert.retry_delivery!
|
|
28
|
+
redirect_back fallback_location: alerts_path, notice: "Retrying alert"
|
|
29
|
+
else
|
|
30
|
+
redirect_back fallback_location: alerts_path, alert: "Only failed alerts can be retried"
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
# Scoped through the integration rather than the rule's application: a
|
|
37
|
+
# "Send test" alert has no rule, and its destination is what owns it.
|
|
38
|
+
def account_alerts
|
|
39
|
+
Alert.all
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def row(a)
|
|
43
|
+
{ id: a.id, event: a.event, status: a.status, sent_at: a.sent_at, error: a.error, payload: a.payload, created_at: a.created_at,
|
|
44
|
+
integration: { kind: a.integration.kind, name: a.integration.name },
|
|
45
|
+
issue: a.issue && { id: a.issue.id, key: a.issue.key, title: a.issue.title } }
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Railwatch
|
|
4
|
+
class AnomalyRulesController < DashboardController
|
|
5
|
+
def create
|
|
6
|
+
rule = environment.anomaly_rules.new(anomaly_rule_params)
|
|
7
|
+
if rule.save
|
|
8
|
+
redirect_to application_environment_thresholds_path(application, environment), notice: "Anomaly rule added"
|
|
9
|
+
else
|
|
10
|
+
redirect_to application_environment_thresholds_path(application, environment), inertia: { errors: rule.errors }
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def update
|
|
15
|
+
rule = environment.anomaly_rules.find(params[:id])
|
|
16
|
+
rule.update(anomaly_rule_params)
|
|
17
|
+
redirect_to application_environment_thresholds_path(application, environment), notice: "Anomaly rule updated"
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def destroy
|
|
21
|
+
environment.anomaly_rules.find(params[:id]).destroy
|
|
22
|
+
redirect_to application_environment_thresholds_path(application, environment), notice: "Anomaly rule removed"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def anomaly_rule_params
|
|
28
|
+
params.permit(:target_kind, :target, :metric, :deviation, :window_minutes, :baseline_days, :enabled)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Serves the bytes an app attached to an execution or exception. The row
|
|
4
|
+
# only exists in its own environment's telemetry database, so an id from
|
|
5
|
+
# another environment 404s here.
|
|
6
|
+
module Railwatch
|
|
7
|
+
class AttachmentsController < DashboardController
|
|
8
|
+
def show
|
|
9
|
+
attachment = telemetry { Telemetry::Attachment.find(params[:id]) }
|
|
10
|
+
# Rendered as text/plain even for text/html or SVG: these bytes came
|
|
11
|
+
# from a monitored app and must never execute on our origin.
|
|
12
|
+
if params[:view] == "1" && attachment.viewable?
|
|
13
|
+
send_data attachment.body, type: "text/plain; charset=utf-8", disposition: "inline"
|
|
14
|
+
else
|
|
15
|
+
send_data attachment.body, type: attachment.content_type.presence || "application/octet-stream",
|
|
16
|
+
filename: attachment.name, disposition: "attachment"
|
|
17
|
+
end
|
|
18
|
+
rescue Telemetry::BoundedGzip::Error
|
|
19
|
+
head :unprocessable_content
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
3
5
|
module Railwatch
|
|
4
6
|
# Receives Inertia visit timings and JavaScript errors from the browser
|
|
5
7
|
# client (app/frontend/lib/railwatch.ts). Mounted at /railwatch/beacon.
|
|
@@ -22,7 +24,7 @@ module Railwatch
|
|
|
22
24
|
|
|
23
25
|
RATE_LIMIT_WINDOW = 60 # seconds
|
|
24
26
|
|
|
25
|
-
before_action :limit_payload, :throttle, if: -> { Railwatch.config.beacon_enabled }
|
|
27
|
+
before_action :limit_payload, :verify_origin, :throttle, if: -> { Railwatch.config.beacon_enabled }
|
|
26
28
|
|
|
27
29
|
def create
|
|
28
30
|
return head :no_content unless Railwatch.config.beacon_enabled
|
|
@@ -65,17 +67,75 @@ module Railwatch
|
|
|
65
67
|
# The beacon takes no credential and keeps every browser error it is
|
|
66
68
|
# sent, so a client that is not the page -- a script, a bored visitor
|
|
67
69
|
# with curl -- could otherwise fill the app's quota with junk issues.
|
|
68
|
-
#
|
|
69
|
-
#
|
|
70
|
-
#
|
|
71
|
-
#
|
|
70
|
+
# Two ceilings, both counters in the app's cache store in the shape of
|
|
71
|
+
# Rails' own rate_limit: one per client IP, and one for the endpoint as a
|
|
72
|
+
# whole so a rotating address cannot multiply its way past the first.
|
|
73
|
+
# Read from config per request, so an initializer can raise or remove
|
|
74
|
+
# either one.
|
|
75
|
+
# Same idea as Sentry's allowed domains: a public ingest endpoint cannot
|
|
76
|
+
# authenticate its caller (the credential would be in the page), so it
|
|
77
|
+
# refuses anything that says it came from somewhere else. Not a security
|
|
78
|
+
# boundary -- an Origin header is trivially forged outside a browser --
|
|
79
|
+
# but it stops another site's page from spending this app's quota.
|
|
80
|
+
# Missing Origin and Referer pass, which is how Rails' own forgery origin
|
|
81
|
+
# check behaves.
|
|
82
|
+
def verify_origin
|
|
83
|
+
origin = request.origin || referer_origin
|
|
84
|
+
return if origin.nil? || origin == request.base_url
|
|
85
|
+
return if Railwatch.config.beacon_allowed_origins.any? { |allowed| origin_matches?(origin, allowed) }
|
|
86
|
+
|
|
87
|
+
Railwatch.debug { "refused a beacon from #{origin} (this app is #{request.base_url})" }
|
|
88
|
+
head :forbidden
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def referer_origin
|
|
92
|
+
referer = request.referer.presence or return nil
|
|
93
|
+
uri = URI.parse(referer)
|
|
94
|
+
return nil unless uri.scheme && uri.host
|
|
95
|
+
|
|
96
|
+
port = ":#{uri.port}" if uri.port && uri.port != uri.default_port
|
|
97
|
+
"#{uri.scheme}://#{uri.host}#{port}"
|
|
98
|
+
rescue URI::InvalidURIError
|
|
99
|
+
nil
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# An entry is either a full origin ("https://app.example.com") or a bare
|
|
103
|
+
# host, which matches either scheme.
|
|
104
|
+
def origin_matches?(origin, allowed)
|
|
105
|
+
return true if origin == allowed
|
|
106
|
+
|
|
107
|
+
allowed.include?("://") ? false : [ "https://#{allowed}", "http://#{allowed}" ].include?(origin)
|
|
108
|
+
end
|
|
109
|
+
|
|
72
110
|
def throttle
|
|
73
111
|
limit = Railwatch.config.beacon_rate_limit.to_i
|
|
112
|
+
global = Railwatch.config.beacon_global_rate_limit.to_i
|
|
113
|
+
return refuse_beacon if global.positive? && over?("railwatch:beacon:all", global)
|
|
74
114
|
return unless limit.positive?
|
|
75
115
|
|
|
76
|
-
count
|
|
77
|
-
|
|
116
|
+
# A store that cannot count (NullStore, a cache that is down, one whose
|
|
117
|
+
# increment is unsupported) returns nil. Refuse the beacon then rather
|
|
118
|
+
# than serving an unauthenticated, unlimited write endpoint: the
|
|
119
|
+
# browser client retries on the next flush, and an app that genuinely
|
|
120
|
+
# wants no limit sets beacon_rate_limit to 0.
|
|
121
|
+
refuse_beacon if over?("railwatch:beacon:#{request.remote_ip}", limit)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# True when this key has passed its ceiling for the window, and also when
|
|
125
|
+
# the store cannot count at all: a store that returns nil (NullStore, a
|
|
126
|
+
# cache that is down, one without increment) would otherwise leave an
|
|
127
|
+
# unauthenticated endpoint with no ceiling on it. An app that genuinely
|
|
128
|
+
# wants no limit sets the limit to 0, or turns the beacon off.
|
|
129
|
+
def over?(key, limit)
|
|
130
|
+
count = cache_store.increment(key, 1, expires_in: RATE_LIMIT_WINDOW)
|
|
131
|
+
if count.nil?
|
|
132
|
+
Railwatch.debug { "beacon rate limiting is unavailable (#{cache_store.class}); refusing the beacon" }
|
|
133
|
+
return true
|
|
134
|
+
end
|
|
135
|
+
count > limit
|
|
136
|
+
end
|
|
78
137
|
|
|
138
|
+
def refuse_beacon
|
|
79
139
|
response.set_header("Retry-After", RATE_LIMIT_WINDOW.to_s)
|
|
80
140
|
head :too_many_requests
|
|
81
141
|
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Railwatch
|
|
4
|
+
class BroadcastsController < DashboardController
|
|
5
|
+
def index
|
|
6
|
+
from, to = window_range
|
|
7
|
+
streams = telemetry do
|
|
8
|
+
Telemetry::Broadcast.between(from, to).group(:group_hash, :kind).pluck(:group_hash, :kind, Arel.sql("MAX(COALESCE(stream, channel))"), Arel.sql("COUNT(*)"), Arel.sql("SUM(bytes)"), Arel.sql("AVG(duration)"))
|
|
9
|
+
.map { |g, k, name, c, b, d| { group_hash: g, kind: k, name: name, count: c, bytes: b.to_i, avg: (d.to_f / 1000.0).round(3) } }.sort_by { |r| -r[:count] }.first(200)
|
|
10
|
+
end
|
|
11
|
+
recent = telemetry { Telemetry::Broadcast.between(from, to).recent.limit(100).map { |b| { id: b.id, kind: b.kind, stream: b.stream, channel: b.channel, action: b.action, bytes: b.bytes, failed: b.failed, duration: b.duration_ms.round(3), occurred_at: b.occurred_at, execution_id: b.execution_id, execution_preview: b.execution_preview } } }
|
|
12
|
+
render inertia: { streams: streams, recent: recent }
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Railwatch
|
|
4
|
+
class CacheEventsController < DashboardController
|
|
5
|
+
def index
|
|
6
|
+
from, to = window_range
|
|
7
|
+
fields = FilterQuery.parse(params[:q]).fetch(:fields)
|
|
8
|
+
keys = telemetry do
|
|
9
|
+
scope = Telemetry::Rollup.for_type("cache_event").between(from, to)
|
|
10
|
+
if fields["store"].present?
|
|
11
|
+
# Rollup rows have no store column; find the matching group_hashes
|
|
12
|
+
# from the raw cache_events for this window and filter by those.
|
|
13
|
+
hashes = Telemetry::CacheEvent.between(from, to).where(store: fields["store"]).distinct.pluck(:group_hash)
|
|
14
|
+
scope = scope.where(group_hash: hashes)
|
|
15
|
+
end
|
|
16
|
+
rows = scope.to_a
|
|
17
|
+
rows.group_by(&:group_hash).map do |group_hash, group_rows|
|
|
18
|
+
count = group_rows.sum(&:count)
|
|
19
|
+
hits = group_rows.sum { |r| r.extra["hits"].to_i }
|
|
20
|
+
misses = group_rows.sum { |r| r.extra["misses"].to_i }
|
|
21
|
+
duration_sum = group_rows.sum(&:duration_sum)
|
|
22
|
+
{ group_hash: group_hash, key: group_rows.max_by(&:bucket).name, count: count, hits: hits, misses: misses,
|
|
23
|
+
hit_rate: (hits + misses).zero? ? nil : (hits * 100.0 / (hits + misses)).round(1),
|
|
24
|
+
avg: count.zero? ? 0 : (duration_sum / count / 1000.0).round(3), sparkline: Telemetry::Aggregations.sparkline(group_rows, from, to) }
|
|
25
|
+
end.sort_by { |r| -r[:count] }.first(200)
|
|
26
|
+
end
|
|
27
|
+
render inertia: { keys: keys, series: series("cache_event"), q: params[:q].to_s }
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Railwatch
|
|
4
|
+
class CommandsController < DashboardController
|
|
5
|
+
def index
|
|
6
|
+
rows = telemetry { Telemetry::Execution.commands.between(*window_range).recent.limit(200).to_a }
|
|
7
|
+
render inertia: { commands: grouped("command", limit: 100),
|
|
8
|
+
runs: rows.map { |r| { execution_id: r.execution_id, name: r.name, exit_code: r.status, duration: r.duration_ms.round(1), occurred_at: r.occurred_at, server: r.server, exception_preview: r.exception_preview } } }
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def show
|
|
12
|
+
exe = telemetry { Telemetry::Execution.find_by!(execution_id: params[:id]) }
|
|
13
|
+
render inertia: "executions/show", props: ExecutionPresenter.new(exe, environment).props
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Railwatch
|
|
4
|
+
class CommentsController < DashboardController
|
|
5
|
+
def create
|
|
6
|
+
issue = Issue.all.find(params[:issue_id])
|
|
7
|
+
issue.comments.create!(user: Viewer.user, body: params.require(:body))
|
|
8
|
+
redirect_to issue_path(issue), notice: "Comment added"
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Railwatch
|
|
4
|
+
# Base for every dashboard page: the prebuilt Inertia bundle, the shared
|
|
5
|
+
# props its layouts read, and per-controller Inertia config so a host that
|
|
6
|
+
# also uses Inertia keeps its own.
|
|
7
|
+
#
|
|
8
|
+
# Inherits from Railwatch.config.base_controller_class (default
|
|
9
|
+
# ActionController::Base) so a host can put its own admin gate in front of
|
|
10
|
+
# every page the way Mission Control Jobs allows: point it at a controller
|
|
11
|
+
# whose before_action requires an admin, and turn HTTP Basic off.
|
|
12
|
+
class DashboardController < Railwatch.config.base_controller_class.constantize
|
|
13
|
+
include Railwatch::AssetsHelper
|
|
14
|
+
helper Railwatch::AssetsHelper
|
|
15
|
+
include Railwatch::EnvironmentScoped
|
|
16
|
+
|
|
17
|
+
layout "railwatch/dashboard"
|
|
18
|
+
# The bundle's pages are named after the platform's controllers
|
|
19
|
+
# (overview/show, requests/index); strip the engine namespace so
|
|
20
|
+
# default_render finds them.
|
|
21
|
+
inertia_config version: -> { Railwatch::AssetsHelper.digest }, layout: "railwatch/dashboard",
|
|
22
|
+
use_script_element_for_initial_page: true, always_include_errors_hash: true, default_render: true,
|
|
23
|
+
component_path_resolver: ->(path:, action:) { "#{path.delete_prefix('railwatch/')}/#{action}" }
|
|
24
|
+
rescue_from Telemetry::CursorPage::InvalidCursor do |exception|
|
|
25
|
+
render plain: exception.message, status: :unprocessable_content
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# HTTP Basic, on and closed by default (Configuration#http_basic_auth_*).
|
|
29
|
+
# Runs before anything reads telemetry; a host that authenticates in its
|
|
30
|
+
# base controller or a routes constraint turns it off.
|
|
31
|
+
before_action :authenticate_by_http_basic
|
|
32
|
+
before_action { Viewer.user = Railwatch.config.resolve_dashboard_user(request) }
|
|
33
|
+
|
|
34
|
+
inertia_share auth: -> { { user: Viewer.user.as_json, session: { id: "embedded", recently_authenticated: true } } },
|
|
35
|
+
account: -> { Railwatch::Embedded::Account.as_json },
|
|
36
|
+
accounts: -> { [ { id: 1, name: Railwatch::Embedded::Account.name } ] },
|
|
37
|
+
applications: -> { [ { id: 1, name: Application.current.name, slug: Application.current.slug,
|
|
38
|
+
issue_prefix: Application.current.issue_prefix,
|
|
39
|
+
environments: [ { id: 1, name: Environment.current.name, slug: Environment.current.slug,
|
|
40
|
+
last_seen_at: Environment.current.last_seen_at, paused: false } ] } ] },
|
|
41
|
+
flash: -> { { alert: flash.alert, warning: flash[:warning], notice: flash.notice } },
|
|
42
|
+
google_oauth: false,
|
|
43
|
+
embedded: true
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def authenticate_by_http_basic
|
|
48
|
+
config = Railwatch.config
|
|
49
|
+
return unless config.http_basic_auth_enabled
|
|
50
|
+
|
|
51
|
+
if config.http_basic_auth_configured?
|
|
52
|
+
http_basic_authenticate_or_request_with(name: config.http_basic_auth_user, password: config.http_basic_auth_password,
|
|
53
|
+
realm: "Railwatch")
|
|
54
|
+
else
|
|
55
|
+
# Closed, not open: same as Mission Control with no credentials. The
|
|
56
|
+
# challenge header is still sent so a client or a monitor sees the
|
|
57
|
+
# scheme, and the body says what to do, since a bare 401 looks like a
|
|
58
|
+
# broken install rather than an unconfigured one.
|
|
59
|
+
response.set_header("WWW-Authenticate", %(Basic realm="Railwatch"))
|
|
60
|
+
render plain: "Railwatch: HTTP Basic authentication is on and no credentials are configured, so the " \
|
|
61
|
+
"dashboard is closed. Run `bin/rails railwatch:authentication:configure` (writes " \
|
|
62
|
+
"railwatch.http_basic_auth_user/_password to Rails credentials), or set " \
|
|
63
|
+
"RAILWATCH_HTTP_BASIC_AUTH_USER and _PASSWORD, or turn Basic off " \
|
|
64
|
+
"(c.http_basic_auth_enabled = false) once your own auth gates the mount. See docs/embedded.md.",
|
|
65
|
+
status: :unauthorized
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|