railwatch 0.1.3 → 0.2.0.pre1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +231 -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/docs/records.md +16 -1
- 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/llm.rb +149 -4
- 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
|
@@ -3,7 +3,46 @@
|
|
|
3
3
|
# Railwatch: first-class monitoring for Rails. Every option here can also be
|
|
4
4
|
# set by the RAILWATCH_* env var named in the comment.
|
|
5
5
|
Railwatch.configure do |c|
|
|
6
|
+
<% if options[:local] -%>
|
|
7
|
+
# Telemetry stays in this app's own railwatch_telemetry database and the
|
|
8
|
+
# dashboard is served at /railwatch. No token, no cloud. Put the mount
|
|
9
|
+
# behind your own authentication; this only names who is looking.
|
|
10
|
+
c.transport = :local # RAILWATCH_TRANSPORT
|
|
11
|
+
c.ignored_request_paths += ["/railwatch", %r{\A/railwatch/}]
|
|
12
|
+
# c.issue_prefix = "APP" # RAILWATCH_ISSUE_PREFIX; issue keys like APP-12
|
|
13
|
+
# c.repository_url = "https://github.com/you/app" # RAILWATCH_REPOSITORY_URL; source links from stack traces
|
|
14
|
+
# c.retention_days = 7 # RAILWATCH_RETENTION_DAYS; PruneTelemetryJob keeps this much
|
|
15
|
+
# Access. The dashboard shows every query, log line and exception this app
|
|
16
|
+
# records, so pick one of these. Out of the box it is HTTP Basic, on and
|
|
17
|
+
# closed until credentials exist:
|
|
18
|
+
# bin/rails railwatch:authentication:configure (writes Rails credentials)
|
|
19
|
+
# or RAILWATCH_HTTP_BASIC_AUTH_USER / _PASSWORD.
|
|
20
|
+
#
|
|
21
|
+
# Using your own admin auth instead? Turn Basic off and say which, so the
|
|
22
|
+
# gem knows the mount is covered and live updates can follow your rule:
|
|
23
|
+
# c.http_basic_auth_enabled = false # RAILWATCH_HTTP_BASIC_AUTH_ENABLED
|
|
24
|
+
# c.base_controller_class = "AdminController" # every dashboard page inherits it
|
|
25
|
+
# c.dashboard_user = ->(request) { ... } # or decide per request, below
|
|
26
|
+
#
|
|
27
|
+
# Genuinely public (a private network, a VPN, a constraint the gem cannot
|
|
28
|
+
# see)? Say so on purpose. Nothing else makes the dashboard public, and
|
|
29
|
+
# without one of the above the app logs a warning at every boot:
|
|
30
|
+
# c.dashboard_open = false # RAILWATCH_DASHBOARD_OPEN
|
|
31
|
+
# Who the dashboard names on comments and saved views. Returning nil also
|
|
32
|
+
# refuses the request, so this doubles as your authorisation rule:
|
|
33
|
+
# c.dashboard_user = ->(request) { user = Current.user; user&.admin? && { id: user.id, name: user.name, email: user.email } }
|
|
34
|
+
|
|
35
|
+
# The browser beacon (Inertia visit timings and JavaScript errors) is a
|
|
36
|
+
# public endpoint, like every browser telemetry endpoint: it is bounded by
|
|
37
|
+
# an origin allowlist, a per-client rate limit and a ceiling for the whole
|
|
38
|
+
# endpoint, not by a credential.
|
|
39
|
+
# c.beacon_enabled = false # RAILWATCH_BEACON
|
|
40
|
+
# c.beacon_allowed_origins = ["https://app.example.com"] # beyond this app's own
|
|
41
|
+
# c.beacon_rate_limit = 120 # per client IP per minute
|
|
42
|
+
# c.beacon_global_rate_limit = 6_000 # for the endpoint, per minute
|
|
43
|
+
<% else -%>
|
|
6
44
|
# c.token = ENV["RAILWATCH_TOKEN"] # RAILWATCH_TOKEN (required)
|
|
45
|
+
<% end -%>
|
|
7
46
|
# c.ingest_url = "https://railwatch.rebulk.com" # RAILWATCH_INGEST_URL
|
|
8
47
|
# c.deploy = "release-name" # RAILWATCH_DEPLOY; platform/Git auto-detected
|
|
9
48
|
# c.detect_deploy = false # RAILWATCH_DETECT_DEPLOY; default true
|
|
@@ -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
|
-
|
|
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, :
|
|
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,51 @@
|
|
|
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
|
+
def users = [ User.default ]
|
|
38
|
+
def members = users
|
|
39
|
+
def memberships = NONE
|
|
40
|
+
def integrations = NONE
|
|
41
|
+
def applications = [ Application.current ]
|
|
42
|
+
def environments = [ Environment.current ]
|
|
43
|
+
def quota_exhausted? = false
|
|
44
|
+
def as_json(*) = { id: id, name: name, slug: slug, plan: plan }
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
class << self
|
|
48
|
+
attr_accessor :last_seen_at
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|