railwatch 0.1.4 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +212 -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_migrate/20260918120000_widen_host_user_ids.rb +71 -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 +352 -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 +54 -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 +314 -4
|
@@ -8,6 +8,14 @@
|
|
|
8
8
|
# (https://kamal-deploy.org/docs/hooks/overview/), so it POSTs straight to the
|
|
9
9
|
# ingest host instead of shelling into a container. Never fails a deploy.
|
|
10
10
|
set -e
|
|
11
|
+
|
|
12
|
+
# Embedded install (transport = :local): the marker is a row in the app's own
|
|
13
|
+
# railwatch database, so record it inside the deployed container and stop.
|
|
14
|
+
if [ "${RAILWATCH_TRANSPORT:-}" = "local" ]; then
|
|
15
|
+
bin/kamal app exec --primary --reuse "bin/rails railwatch:deploy[$KAMAL_VERSION]" || true
|
|
16
|
+
exit 0
|
|
17
|
+
fi
|
|
18
|
+
|
|
11
19
|
[ -z "$RAILWATCH_TOKEN" ] && exit 0
|
|
12
20
|
|
|
13
21
|
# Optional: .kamal/hooks/post-deploy --sourcemaps, or set
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "puma/plugin"
|
|
4
|
+
|
|
5
|
+
# `plugin :railwatch` in config/puma.rb. In embedded mode, forks one
|
|
6
|
+
# Railwatch::Writer from the Puma process once it has booted, restarts it if
|
|
7
|
+
# it dies, and stops it with Puma. Same shape as Solid Queue's
|
|
8
|
+
# `solid_queue_mode :fork`, in both cluster and single mode: a default Rails
|
|
9
|
+
# 8 app runs Puma in single mode (no WEB_CONCURRENCY), and that app's
|
|
10
|
+
# batches belong off its request threads just as much. The fork happens
|
|
11
|
+
# from the plugin's background thread with the app loaded and Rails'
|
|
12
|
+
# ForkTracker resetting every Railwatch thread in the child, exactly as it
|
|
13
|
+
# does for a Puma cluster worker. A no-op when Railwatch is off or the
|
|
14
|
+
# transport is not :local, so it is safe to leave in place.
|
|
15
|
+
#
|
|
16
|
+
# Nothing in `start` may touch Railwatch: `bundle exec puma` fires plugin
|
|
17
|
+
# starts BEFORE it loads the Rack app, so the constant may not exist yet.
|
|
18
|
+
# Every decision that needs the app is made from the background supervisor,
|
|
19
|
+
# which waits for after_booted.
|
|
20
|
+
Puma::Plugin.create do
|
|
21
|
+
attr_reader :log_writer, :writer_pid
|
|
22
|
+
|
|
23
|
+
POLL = 2
|
|
24
|
+
|
|
25
|
+
def start(launcher)
|
|
26
|
+
@log_writer = launcher.log_writer
|
|
27
|
+
@puma_pid = $$
|
|
28
|
+
@launcher = launcher
|
|
29
|
+
@shutting_down = false
|
|
30
|
+
@booted = false
|
|
31
|
+
@warned = false
|
|
32
|
+
|
|
33
|
+
# When the app is already loaded (`bin/rails server` loads it before
|
|
34
|
+
# handing over to Puma), mark a writer expected now: cluster workers fork
|
|
35
|
+
# from this process after start and inherit the flag, so they retain
|
|
36
|
+
# batches while the writer is starting instead of writing them in-process.
|
|
37
|
+
# Under bare `puma` this is false and the workers find the writer on
|
|
38
|
+
# their transport's next re-check instead (Transport::Socket).
|
|
39
|
+
::Railwatch::Writer.expected! if active?
|
|
40
|
+
|
|
41
|
+
# in_background blocks are collected at plugin start and started by the
|
|
42
|
+
# runner once, so this has to be registered here, not from after_booted
|
|
43
|
+
# (by then they have already been fired and a late block never runs). It
|
|
44
|
+
# waits for boot itself.
|
|
45
|
+
in_background { supervise }
|
|
46
|
+
|
|
47
|
+
launcher.events.after_booted { booted! }
|
|
48
|
+
# A phased restart fires before_restart and then, once the new workers
|
|
49
|
+
# are up, after_booted again. The writer is stopped for the restart and
|
|
50
|
+
# the supervisor, which is still running, respawns it when @booted flips
|
|
51
|
+
# back. Only a real stop latches shutdown.
|
|
52
|
+
launcher.events.before_restart { restart_writer }
|
|
53
|
+
# after_stopped only latches. It is NOT where the writer is stopped:
|
|
54
|
+
# Puma's SIGTERM trap fires it BEFORE `stop_blocked` drains in-flight
|
|
55
|
+
# requests, so stopping here would take the writer away from requests
|
|
56
|
+
# that are still running. at_exit runs after the whole run loop.
|
|
57
|
+
launcher.events.after_stopped { @shutting_down = true }
|
|
58
|
+
at_exit { shutdown_writer if Process.pid == @puma_pid }
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
def booted!
|
|
64
|
+
@booted = true
|
|
65
|
+
::Railwatch::Writer.expected! if active?
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Whether this process should run a writer. Answered fresh each time, and
|
|
69
|
+
# safe before the app is loaded: `defined?(Railwatch)` is true as soon as
|
|
70
|
+
# the gem's entrypoint has been required (Bundler.require), but a bare
|
|
71
|
+
# `puma` on an app that has not required it yet sees neither, and must not
|
|
72
|
+
# raise out of Puma's boot.
|
|
73
|
+
def active?
|
|
74
|
+
return false unless defined?(::Railwatch) && ::Railwatch.respond_to?(:enabled?)
|
|
75
|
+
return false unless ::Railwatch.enabled? && ::Railwatch.config.local?
|
|
76
|
+
|
|
77
|
+
path = ::Railwatch.config.writer_socket_path
|
|
78
|
+
return false if path.nil?
|
|
79
|
+
return true if ::Railwatch::Writer.usable_path?(path)
|
|
80
|
+
|
|
81
|
+
unless @warned
|
|
82
|
+
@warned = true
|
|
83
|
+
log "Railwatch writer not started: socket path #{path} is #{path.bytesize} bytes, over the " \
|
|
84
|
+
"#{::Railwatch::Writer::MAX_SOCKET_PATH}-byte limit; set RAILWATCH_WRITER_SOCKET to a shorter path. " \
|
|
85
|
+
"Batches are written in-process meanwhile."
|
|
86
|
+
end
|
|
87
|
+
false
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def spawn_writer
|
|
91
|
+
@writer_pid = ::Railwatch::Writer.fork_writer! do
|
|
92
|
+
::Railwatch::Writer.run!(parent: @puma_pid)
|
|
93
|
+
end
|
|
94
|
+
log "Railwatch writer started (pid #{@writer_pid})"
|
|
95
|
+
rescue SystemCallError => e
|
|
96
|
+
@writer_pid = nil
|
|
97
|
+
log "Railwatch writer could not be forked (#{e.class}: #{e.message}); retrying"
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Puma's cluster reaps every child with wait2(-1), the writer included, so
|
|
101
|
+
# waitpid on the writer's pid raises ECHILD after it has died. Liveness is
|
|
102
|
+
# asked with signal 0 instead, which works whoever reaped it.
|
|
103
|
+
def supervise
|
|
104
|
+
loop do
|
|
105
|
+
sleep POLL
|
|
106
|
+
break if @shutting_down
|
|
107
|
+
next unless @booted && active?
|
|
108
|
+
next if writer_alive?
|
|
109
|
+
|
|
110
|
+
log "Railwatch writer (pid #{@writer_pid}) is gone; starting" if @writer_pid
|
|
111
|
+
spawn_writer
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def writer_alive?
|
|
116
|
+
return false unless @writer_pid
|
|
117
|
+
|
|
118
|
+
Process.kill(0, @writer_pid)
|
|
119
|
+
# A zombie the cluster has not reaped yet still answers signal 0.
|
|
120
|
+
!Process.waitpid(@writer_pid, Process::WNOHANG)
|
|
121
|
+
rescue Errno::ESRCH
|
|
122
|
+
false
|
|
123
|
+
rescue Errno::ECHILD
|
|
124
|
+
# Already reaped by the cluster: alive iff it still exists for kill 0.
|
|
125
|
+
begin
|
|
126
|
+
Process.kill(0, @writer_pid)
|
|
127
|
+
true
|
|
128
|
+
rescue Errno::ESRCH
|
|
129
|
+
false
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# For a restart: stop the writer and mark the cluster as not booted, so the
|
|
134
|
+
# supervisor spawns a fresh one once after_booted fires again.
|
|
135
|
+
def restart_writer
|
|
136
|
+
@booted = false
|
|
137
|
+
stop_writer
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Runs from at_exit, after Puma's run loop has returned and every request
|
|
141
|
+
# has been served, in both single and cluster mode. Flushes this process's
|
|
142
|
+
# own reporter (its process and health records, and in single mode the
|
|
143
|
+
# requests' records) while the writer is still listening, then stops it.
|
|
144
|
+
def shutdown_writer
|
|
145
|
+
@shutting_down = true
|
|
146
|
+
if defined?(::Railwatch) && ::Railwatch.respond_to?(:enabled?) && ::Railwatch.enabled? && ::Railwatch.config.local?
|
|
147
|
+
::Railwatch.reporter.shutdown
|
|
148
|
+
end
|
|
149
|
+
stop_writer
|
|
150
|
+
rescue StandardError => e
|
|
151
|
+
log "Railwatch writer shutdown failed (#{e.class}: #{e.message})"
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# TERM closes the writer's listener and it drains what it is holding; the
|
|
155
|
+
# wait also reaps it, so a cluster master never leaves a zombie behind.
|
|
156
|
+
def stop_writer
|
|
157
|
+
return unless @writer_pid
|
|
158
|
+
|
|
159
|
+
Process.kill(:TERM, @writer_pid)
|
|
160
|
+
Process.wait(@writer_pid)
|
|
161
|
+
rescue Errno::ECHILD, Errno::ESRCH
|
|
162
|
+
nil
|
|
163
|
+
ensure
|
|
164
|
+
@writer_pid = nil
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def log(message)
|
|
168
|
+
log_writer.log(message)
|
|
169
|
+
end
|
|
170
|
+
end
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
|
|
5
|
+
module Railwatch
|
|
6
|
+
# `bin/rails railwatch:authentication:configure`: stores the embedded
|
|
7
|
+
# dashboard's HTTP Basic credentials in the current environment's Rails
|
|
8
|
+
# credentials, the same way Mission Control Jobs' authentication:configure
|
|
9
|
+
# does. Asks for a user, generates the password, appends a `railwatch:`
|
|
10
|
+
# entry to the credentials file the environment reads.
|
|
11
|
+
module Authentication
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
def configure(io: $stdin, out: $stdout)
|
|
15
|
+
unless credentials_accessible?
|
|
16
|
+
out.puts "Rails credentials are not configured or the key is not available for `#{Rails.env}`. " \
|
|
17
|
+
"Set them up (`bin/rails credentials:help`) or set RAILWATCH_HTTP_BASIC_AUTH_USER and " \
|
|
18
|
+
"RAILWATCH_HTTP_BASIC_AUTH_PASSWORD in the environment instead."
|
|
19
|
+
return false
|
|
20
|
+
end
|
|
21
|
+
if configured?
|
|
22
|
+
out.puts "HTTP Basic authentication is already configured for `#{Rails.env}` (railwatch.http_basic_auth_user); " \
|
|
23
|
+
"edit it with `bin/rails credentials:edit#{env_flag}`."
|
|
24
|
+
return false
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
out.print "Enter username for the Railwatch dashboard (#{Rails.env}): "
|
|
28
|
+
username = io.gets.to_s.strip
|
|
29
|
+
if username.empty?
|
|
30
|
+
out.puts "No username given; nothing written."
|
|
31
|
+
return false
|
|
32
|
+
end
|
|
33
|
+
password = SecureRandom.base58(48)
|
|
34
|
+
# Merge, never append. A second top-level `railwatch:` mapping would
|
|
35
|
+
# win in Psych and silently hide whatever the first one held, and a
|
|
36
|
+
# username with YAML syntax in it would reshape the file; round-tripping
|
|
37
|
+
# through the parser and to_yaml quotes it correctly.
|
|
38
|
+
merged = (YAML.safe_load(credentials.read.to_s, permitted_classes: [ Symbol ], aliases: true) || {})
|
|
39
|
+
unless merged.is_a?(Hash)
|
|
40
|
+
out.puts "#{credentials.content_path} does not contain a YAML mapping; edit it by hand."
|
|
41
|
+
return false
|
|
42
|
+
end
|
|
43
|
+
merged["railwatch"] = (merged["railwatch"].is_a?(Hash) ? merged["railwatch"] : {})
|
|
44
|
+
.merge("http_basic_auth_user" => username, "http_basic_auth_password" => password)
|
|
45
|
+
credentials.write(merged.to_yaml)
|
|
46
|
+
|
|
47
|
+
out.puts <<~DONE
|
|
48
|
+
Stored in #{credentials.content_path.relative_path_from(Rails.root)} under `railwatch:`.
|
|
49
|
+
|
|
50
|
+
The dashboard at /railwatch now asks for:
|
|
51
|
+
user: #{username}
|
|
52
|
+
password: #{password}
|
|
53
|
+
|
|
54
|
+
Edit later with `bin/rails credentials:edit#{env_flag}`. Restart the app to pick it up.
|
|
55
|
+
DONE
|
|
56
|
+
true
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def credentials_accessible?
|
|
60
|
+
credentials.read.present?
|
|
61
|
+
rescue ActiveSupport::EncryptedFile::MissingKeyError, ActiveSupport::MessageEncryptor::InvalidMessage
|
|
62
|
+
false
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Both halves, so a file holding only one of them is repaired rather than
|
|
66
|
+
# refused (which would leave the dashboard closed with no way forward).
|
|
67
|
+
def configured?
|
|
68
|
+
%i[http_basic_auth_user http_basic_auth_password].all? { |key| credentials.dig(:railwatch, key).present? }
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# The file Rails.application.credentials reads for this environment:
|
|
72
|
+
# config/credentials/<env>.yml.enc when that environment has one, else
|
|
73
|
+
# config/credentials.yml.enc.
|
|
74
|
+
def credentials
|
|
75
|
+
config = Rails.application.config.credentials
|
|
76
|
+
Rails.application.encrypted(config.content_path, key_path: config.key_path)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def env_flag
|
|
80
|
+
Rails.root.join("config/credentials/#{Rails.env}.yml.enc").exist? ? " --environment #{Rails.env}" : ""
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -60,7 +60,15 @@ module Railwatch
|
|
|
60
60
|
Rack::QueryParser::ParameterTypeError
|
|
61
61
|
].freeze
|
|
62
62
|
|
|
63
|
-
|
|
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,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Railwatch
|
|
4
|
+
# Process-local state for the embedded (in-process) mode and the stand-ins
|
|
5
|
+
# the platform's code expects from its account layer.
|
|
6
|
+
module Embedded
|
|
7
|
+
# Relation-shaped empty set for platform associations an embedded install
|
|
8
|
+
# does not have (Linear links, integrations). Every scope returns itself.
|
|
9
|
+
NONE = Class.new do
|
|
10
|
+
def method_missing(*) = self
|
|
11
|
+
def respond_to_missing?(*) = true
|
|
12
|
+
def to_a = []
|
|
13
|
+
def each(&) = [].each(&)
|
|
14
|
+
def first = nil
|
|
15
|
+
def map(&) = []
|
|
16
|
+
def count = 0
|
|
17
|
+
def any? = false
|
|
18
|
+
def none? = true
|
|
19
|
+
def exists?(*) = false
|
|
20
|
+
def empty? = true
|
|
21
|
+
def limit(*) = self
|
|
22
|
+
def where(*) = self
|
|
23
|
+
def find_each(&) = nil
|
|
24
|
+
end.new.freeze
|
|
25
|
+
|
|
26
|
+
# The platform scopes everything by account (plan, quota, retention,
|
|
27
|
+
# members). One embedded install is one account with none of that.
|
|
28
|
+
module Account
|
|
29
|
+
module_function
|
|
30
|
+
|
|
31
|
+
def id = 1
|
|
32
|
+
def name = Rails.application.class.module_parent_name
|
|
33
|
+
def slug = name.parameterize
|
|
34
|
+
def plan = "embedded"
|
|
35
|
+
def retention_days = Railwatch.config.retention_days
|
|
36
|
+
def auto_resolve_after_days = 14
|
|
37
|
+
# Whoever the host's resolver named for this request, not the anonymous
|
|
38
|
+
# placeholder: an embedded install has no member list, so the account's
|
|
39
|
+
# one "member" is the person looking at it.
|
|
40
|
+
def users = [ User.current || User.default ]
|
|
41
|
+
def members = users
|
|
42
|
+
def memberships = NONE
|
|
43
|
+
def integrations = NONE
|
|
44
|
+
def applications = [ Application.current ]
|
|
45
|
+
def environments = [ Environment.current ]
|
|
46
|
+
def quota_exhausted? = false
|
|
47
|
+
def as_json(*) = { id: id, name: name, slug: slug, plan: plan }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
class << self
|
|
51
|
+
attr_accessor :last_seen_at
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
data/lib/railwatch/engine.rb
CHANGED
|
@@ -1,11 +1,56 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
# The dashboard controllers are Inertia controllers; a host that does not use
|
|
4
|
+
# Inertia itself never requires the gem, so the engine does.
|
|
5
|
+
require "inertia_rails"
|
|
6
|
+
# The engine's jobs (grouping, rollups, scans) are Active Job classes even
|
|
7
|
+
# though embedded mode calls their perform directly, so a host built with
|
|
8
|
+
# `rails new --minimal` (no Active Job) still needs the framework loaded.
|
|
9
|
+
# Part of the rails gem this one depends on, so nothing new is pulled in.
|
|
10
|
+
require "active_job/railtie"
|
|
11
|
+
|
|
3
12
|
module Railwatch
|
|
13
|
+
# isolate_namespace would give every model a railwatch_ prefix. Only the
|
|
14
|
+
# engine's own records (issues, comments, deploys: Railwatch::ApplicationRecord)
|
|
15
|
+
# carry it; the telemetry tables are the hosted platform's schema and stay
|
|
16
|
+
# unprefixed. Defined before isolate_namespace runs, which only adds a
|
|
17
|
+
# prefix when none is set.
|
|
18
|
+
def self.table_name_prefix = ""
|
|
19
|
+
|
|
4
20
|
class Engine < ::Rails::Engine
|
|
5
21
|
isolate_namespace Railwatch
|
|
6
22
|
|
|
7
23
|
config.railwatch = ActiveSupport::OrderedOptions.new
|
|
8
24
|
|
|
25
|
+
# The dashboard bundle is built into the gem at release time; serve it
|
|
26
|
+
# from here so a host app needs no asset pipeline integration at all.
|
|
27
|
+
# Where it goes in the stack is DashboardAssets.install!'s decision.
|
|
28
|
+
initializer "railwatch.dashboard_assets" do |app|
|
|
29
|
+
Railwatch::DashboardAssets.install!(app, root: root.join("public/railwatch").to_s)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Action Cable is optional: live dashboard updates need it, nothing else
|
|
33
|
+
# does. A host without it (`rails new --minimal`, --skip-action-cable)
|
|
34
|
+
# must not eager-load the engine's channel, which inherits from a class
|
|
35
|
+
# that does not exist there.
|
|
36
|
+
initializer "railwatch.channels", before: :setup_main_autoloader do
|
|
37
|
+
Rails.autoloaders.main.ignore(root.join("app/channels")) unless defined?(::ActionCable)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# The dashboard bundle subscribes to "EnvironmentChannel" by name; Action
|
|
41
|
+
# Cable constantizes that, so the engine's channel needs the bare name.
|
|
42
|
+
# Only defined when the host has not got one of its own.
|
|
43
|
+
initializer "railwatch.live_channel" do
|
|
44
|
+
next unless defined?(::ActionCable)
|
|
45
|
+
|
|
46
|
+
# Action Cable constantizes the subscription's channel name at
|
|
47
|
+
# subscribe time; the alias only has to exist by then, and it must
|
|
48
|
+
# not clobber a host channel of the same name.
|
|
49
|
+
config.to_prepare do
|
|
50
|
+
Object.const_set(:EnvironmentChannel, Railwatch::EnvironmentChannel) unless Object.const_defined?(:EnvironmentChannel)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
9
54
|
# The request middleware goes first so wall time includes every other
|
|
10
55
|
# middleware, exactly like Nightwatch's GlobalMiddleware.
|
|
11
56
|
initializer "railwatch.middleware", before: :load_config_initializers do |app|
|
|
@@ -18,8 +63,39 @@ module Railwatch
|
|
|
18
63
|
Railwatch::Console.silence!
|
|
19
64
|
end
|
|
20
65
|
|
|
66
|
+
# Same source Mission Control Jobs reads: railwatch.http_basic_auth_user
|
|
67
|
+
# and _password in Rails credentials, which `bin/rails
|
|
68
|
+
# railwatch:authentication:configure` writes. An initializer or env var
|
|
69
|
+
# that already set them wins.
|
|
70
|
+
initializer "railwatch.http_basic_auth", after: :load_config_initializers do |app|
|
|
71
|
+
config = Railwatch.config
|
|
72
|
+
config.http_basic_auth_user ||= app.credentials.dig(:railwatch, :http_basic_auth_user)
|
|
73
|
+
config.http_basic_auth_password ||= app.credentials.dig(:railwatch, :http_basic_auth_password)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Two things worth one line in the log at boot, because both are
|
|
77
|
+
# invisible until something is already wrong: a Rails/json pair that
|
|
78
|
+
# cannot decode, and an embedded dashboard with nothing declared in
|
|
79
|
+
# front of it.
|
|
80
|
+
initializer "railwatch.warnings", after: :load_config_initializers do
|
|
81
|
+
config.after_initialize do
|
|
82
|
+
next unless Railwatch.enabled?
|
|
83
|
+
|
|
84
|
+
Rails.logger.warn("[railwatch] #{Railwatch::JsonCompat.advice}") if Railwatch::JsonCompat.broken?
|
|
85
|
+
|
|
86
|
+
if Railwatch.config.local? && Railwatch.config.dashboard_gate == :undeclared && !Rails.env.local?
|
|
87
|
+
Rails.logger.warn(
|
|
88
|
+
"[railwatch] the dashboard at the engine's mount has no gate this gem can see: HTTP Basic is off and " \
|
|
89
|
+
"no base_controller_class, dashboard_user or dashboard_open is set. If a routes constraint or your " \
|
|
90
|
+
"network already gates it, set `c.dashboard_open = true` to say so (it also enables live updates); " \
|
|
91
|
+
"otherwise anyone who can reach the URL can read every query, log line and exception this app records."
|
|
92
|
+
)
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
21
97
|
initializer "railwatch.transport_security", after: :load_config_initializers do
|
|
22
|
-
next if Railwatch.config.ingest_url_allowed?
|
|
98
|
+
next if Railwatch.config.local? || Railwatch.config.ingest_url_allowed?
|
|
23
99
|
|
|
24
100
|
Rails.logger.warn(
|
|
25
101
|
"Railwatch will not send telemetry to #{Railwatch.config.ingest_url}: plain HTTP is allowed only for loopback " \
|
|
@@ -129,6 +205,17 @@ module Railwatch
|
|
|
129
205
|
at_exit { Railwatch::Sessions.stop! }
|
|
130
206
|
end
|
|
131
207
|
|
|
208
|
+
# The embedded install's maintenance clock (release health, scans,
|
|
209
|
+
# pruning). Same shape and ordering rationale as "railwatch.health":
|
|
210
|
+
# stopped before the reporter's final flush. Maintenance.start! is a
|
|
211
|
+
# no-op unless the transport is local.
|
|
212
|
+
initializer "railwatch.maintenance" do
|
|
213
|
+
next unless Railwatch.enabled?
|
|
214
|
+
|
|
215
|
+
Railwatch::Maintenance.start!
|
|
216
|
+
at_exit { Railwatch::Maintenance.stop! }
|
|
217
|
+
end
|
|
218
|
+
|
|
132
219
|
# lib/tasks/railwatch_tasks.rake is picked up by Rails::Engine's default
|
|
133
220
|
# lib/tasks convention; the rake_tasks block above only installs the
|
|
134
221
|
# Rake::Task patch.
|
|
@@ -138,6 +225,7 @@ end
|
|
|
138
225
|
require "railwatch/console"
|
|
139
226
|
require "railwatch/health"
|
|
140
227
|
require "railwatch/sessions"
|
|
228
|
+
require "railwatch/maintenance"
|
|
141
229
|
require "railwatch/middleware/request"
|
|
142
230
|
require "railwatch/job_tracing"
|
|
143
231
|
require "railwatch/controller_helpers"
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# The hosted platform bounds ingest request bodies with Rack middleware. In
|
|
4
|
+
# an embedded install nothing arrives over HTTP, but Ingest::Payload and
|
|
5
|
+
# Telemetry::Attachment still size their limits from this constant.
|
|
6
|
+
module Railwatch
|
|
7
|
+
module IngestRequestBodyLimit
|
|
8
|
+
MAX_BYTES = 32 * 1024 * 1024
|
|
9
|
+
end
|
|
10
|
+
end
|