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
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Railwatch
|
|
4
|
+
module Telemetry
|
|
5
|
+
# A tenant of the monitored application, as seen in its telemetry. Not an
|
|
6
|
+
# Active Record table: `app_tenant` is a column on every telemetry row (the
|
|
7
|
+
# gem fills it from Railwatch::Context.current_tenant, which multi-tenant
|
|
8
|
+
# Rails apps get from ActiveRecord::Tenanted / TenantRecord.current_tenant),
|
|
9
|
+
# so a "tenant" only exists as a GROUP BY over the raw tables.
|
|
10
|
+
#
|
|
11
|
+
# Telemetry::Rollup -- what every other page's numbers come from -- is keyed
|
|
12
|
+
# by record type and group_hash only and has no tenant dimension, so all of
|
|
13
|
+
# this is computed from raw rows in the window. Every method must run inside
|
|
14
|
+
# environment.with_telemetry { }.
|
|
15
|
+
class Tenant
|
|
16
|
+
MAX_TENANTS = 200
|
|
17
|
+
# A p95 costs one ordered query per tenant (SQLite has no percentile
|
|
18
|
+
# aggregate), so only the busiest tenants get one; the rest report 0.
|
|
19
|
+
P95_TENANTS = 50
|
|
20
|
+
SPARKLINE_BUCKETS = 12
|
|
21
|
+
SERIES_BUCKETS = 48
|
|
22
|
+
SORTS = { "requests" => :requests, "errors" => :errors, "p95" => :p95, "users" => :users }.freeze
|
|
23
|
+
|
|
24
|
+
ERRORS_SQL = Arel.sql("SUM(CASE WHEN status >= 500 THEN 1 ELSE 0 END)")
|
|
25
|
+
CLIENT_ERRORS_SQL = Arel.sql("SUM(CASE WHEN status BETWEEN 400 AND 499 THEN 1 ELSE 0 END)")
|
|
26
|
+
FAILED_SQL = Arel.sql("SUM(CASE WHEN outcome = 'failed' THEN 1 ELSE 0 END)")
|
|
27
|
+
COUNT_SQL = Arel.sql("COUNT(*)")
|
|
28
|
+
USERS_SQL = Arel.sql("COUNT(DISTINCT user_ref)")
|
|
29
|
+
|
|
30
|
+
# One aggregate row per tenant seen in the window, filtered by `q`
|
|
31
|
+
# (a substring of the tenant name), sorted, and capped at MAX_TENANTS.
|
|
32
|
+
def self.index(from, to, q: nil, sort: nil, dir: nil)
|
|
33
|
+
rows = Hash.new { |h, tenant| h[tenant] = blank_row(tenant) }
|
|
34
|
+
absorb_requests(rows, from, to, q)
|
|
35
|
+
absorb_jobs(rows, from, to, q)
|
|
36
|
+
absorb_counts(rows, filtered(Telemetry::Exception.between(from, to), q), :exceptions)
|
|
37
|
+
absorb_counts(rows, filtered(Telemetry::Log.between(from, to), q), :logs)
|
|
38
|
+
absorb_last_seen(rows, from, to, q)
|
|
39
|
+
absorb_sparklines(rows, from, to, q)
|
|
40
|
+
absorb_p95s(rows.values.max_by(P95_TENANTS) { |r| r[:requests] }, from, to)
|
|
41
|
+
sorted(rows.values, sort, dir)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Headline numbers for the index page, computed from the rows it shows
|
|
45
|
+
# (so the top-tenant share is a share of the listed tenants' requests)
|
|
46
|
+
# plus the share of requests in the window that carry no tenant at all.
|
|
47
|
+
def self.overview(rows, from, to)
|
|
48
|
+
requests = Telemetry::Execution.requests.between(from, to)
|
|
49
|
+
total = requests.count
|
|
50
|
+
untagged = requests.where(app_tenant: nil).count
|
|
51
|
+
tagged = rows.sum { |r| r[:requests] }
|
|
52
|
+
top = rows.max_by { |r| r[:requests] }
|
|
53
|
+
{
|
|
54
|
+
tenants: rows.size, top_tenant: top && top[:tenant],
|
|
55
|
+
top_share: tagged.zero? ? 0.0 : (top[:requests] * 100.0 / tagged).round(1),
|
|
56
|
+
with_errors: rows.count { |r| r[:errors].positive? },
|
|
57
|
+
untagged_share: total.zero? ? 0.0 : (untagged * 100.0 / total).round(1)
|
|
58
|
+
}
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Window totals for one tenant. Durations in milliseconds, like the rest
|
|
62
|
+
# of the tenant props.
|
|
63
|
+
def self.summary(tenant, from, to)
|
|
64
|
+
requests = Telemetry::Execution.requests.between(from, to).where(app_tenant: tenant)
|
|
65
|
+
count, errors, users = requests.pick(COUNT_SQL, ERRORS_SQL, USERS_SQL)
|
|
66
|
+
jobs, failed_jobs = Telemetry::Execution.jobs.between(from, to).where(app_tenant: tenant).pick(COUNT_SQL, FAILED_SQL)
|
|
67
|
+
{
|
|
68
|
+
requests: count, errors: errors.to_i, p95: p95_ms(requests, count), jobs: jobs, failed_jobs: failed_jobs.to_i,
|
|
69
|
+
exceptions: Telemetry::Exception.between(from, to).where(app_tenant: tenant).count,
|
|
70
|
+
users: users, logs: Telemetry::Log.between(from, to).where(app_tenant: tenant).count
|
|
71
|
+
}
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Request volume and duration bucketed over the window, SeriesPoint-shaped
|
|
75
|
+
# so the same charts render it. Percentiles are approximations: these
|
|
76
|
+
# buckets come from raw rows, without the per-bucket t-digest
|
|
77
|
+
# Telemetry::Rollup keeps, so p50 is the bucket's average and p95/p99 its
|
|
78
|
+
# max.
|
|
79
|
+
def self.series(tenant, from, to)
|
|
80
|
+
width = bucket_width(from, to, SERIES_BUCKETS)
|
|
81
|
+
bucket = bucket_sql(from, width)
|
|
82
|
+
Telemetry::Execution.requests.between(from, to).where(app_tenant: tenant).group(bucket)
|
|
83
|
+
.pluck(bucket, COUNT_SQL, ERRORS_SQL, CLIENT_ERRORS_SQL, Arel.sql("SUM(duration)"), Arel.sql("MAX(duration)"))
|
|
84
|
+
.map { |index, count, errors, client_errors, sum, max|
|
|
85
|
+
avg = ms(sum / count)
|
|
86
|
+
{ t: bucket_at(from, width, index, SERIES_BUCKETS).iso8601, count: count, errors: errors, client_errors: client_errors,
|
|
87
|
+
avg: avg, p50: avg, p95: ms(max), p99: ms(max) }
|
|
88
|
+
}.sort_by { |point| point[:t] }
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def self.routes(tenant, from, to, limit: 20)
|
|
92
|
+
grouped_executions(Telemetry::Execution.requests, tenant, from, to, ERRORS_SQL, :errors, limit)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def self.job_classes(tenant, from, to, limit: 10)
|
|
96
|
+
grouped_executions(Telemetry::Execution.jobs, tenant, from, to, FAILED_SQL, :failed, limit)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def self.exceptions(tenant, from, to, limit: 20)
|
|
100
|
+
Telemetry::Exception.between(from, to).where(app_tenant: tenant).recent.limit(limit).map do |e|
|
|
101
|
+
{ id: e.id, class_name: e.class_name, message: e.message.first(500), occurred_at: e.occurred_at,
|
|
102
|
+
execution_id: e.execution_id, group_hash: e.group_hash }
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def self.people(tenant, limit: 20)
|
|
107
|
+
Telemetry::Person.where(app_tenant: tenant).recent.limit(limit).map do |person|
|
|
108
|
+
{ ref: person.ref, name: person.display_name, email: person.email, last_seen_at: person.last_seen_at }
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Same field shape as RequestsController#execution_row, for the tenant
|
|
113
|
+
# page's "Recent requests" table.
|
|
114
|
+
def self.recent_requests(tenant, from, to, limit: 50)
|
|
115
|
+
Telemetry::Execution.requests.between(from, to).where(app_tenant: tenant).recent.limit(limit).map do |r|
|
|
116
|
+
{ execution_id: r.execution_id, name: r.name, status: r.status, duration: r.duration_ms.round(2), occurred_at: r.occurred_at,
|
|
117
|
+
user_ref: r.user_ref, tenant: r.app_tenant, exception_preview: r.exception_preview, inertia_component: r.inertia_component,
|
|
118
|
+
queries: r.counters["queries"], deploy: r.deploy }
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# -- Aggregation steps -----------------------------------------------------
|
|
123
|
+
|
|
124
|
+
def self.absorb_requests(rows, from, to, q)
|
|
125
|
+
filtered(Telemetry::Execution.requests.between(from, to), q).group(:app_tenant)
|
|
126
|
+
.pluck(:app_tenant, COUNT_SQL, ERRORS_SQL, Arel.sql("AVG(duration)"), Arel.sql("MAX(duration)"), USERS_SQL)
|
|
127
|
+
.each do |tenant, count, errors, avg, max, users|
|
|
128
|
+
rows[tenant].merge!(requests: count, errors: errors, avg: ms(avg), max: ms(max), users: users)
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def self.absorb_jobs(rows, from, to, q)
|
|
133
|
+
filtered(Telemetry::Execution.jobs.between(from, to), q).group(:app_tenant)
|
|
134
|
+
.pluck(:app_tenant, COUNT_SQL, FAILED_SQL)
|
|
135
|
+
.each { |tenant, count, failed| rows[tenant].merge!(jobs: count, failed_jobs: failed) }
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def self.absorb_counts(rows, scope, key)
|
|
139
|
+
scope.group(:app_tenant).count.each { |tenant, count| rows[tenant][key] = count }
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def self.absorb_last_seen(rows, from, to, q)
|
|
143
|
+
filtered(Telemetry::Execution.between(from, to), q).group(:app_tenant).maximum(:occurred_at)
|
|
144
|
+
.each { |tenant, at| rows[tenant][:last_seen_at] = at }
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def self.absorb_sparklines(rows, from, to, q)
|
|
148
|
+
width = bucket_width(from, to, SPARKLINE_BUCKETS)
|
|
149
|
+
counts = filtered(Telemetry::Execution.requests.between(from, to), q).group(:app_tenant, bucket_sql(from, width)).count
|
|
150
|
+
counts.each do |(tenant, index), count|
|
|
151
|
+
rows[tenant][:sparkline][[ index.to_i, SPARKLINE_BUCKETS - 1 ].min] += count
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def self.absorb_p95s(rows, from, to)
|
|
156
|
+
rows.each do |row|
|
|
157
|
+
scope = Telemetry::Execution.requests.between(from, to).where(app_tenant: row[:tenant])
|
|
158
|
+
row[:p95] = p95_ms(scope, row[:requests])
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# -- Helpers ---------------------------------------------------------------
|
|
163
|
+
|
|
164
|
+
def self.blank_row(tenant)
|
|
165
|
+
{ tenant: tenant, requests: 0, errors: 0, avg: 0.0, max: 0.0, p95: 0.0, jobs: 0, failed_jobs: 0,
|
|
166
|
+
exceptions: 0, logs: 0, users: 0, last_seen_at: nil, sparkline: Array.new(SPARKLINE_BUCKETS, 0) }
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# Tenant-tagged rows only: a NULL app_tenant never matches LIKE either.
|
|
170
|
+
def self.filtered(scope, q)
|
|
171
|
+
return scope.where.not(app_tenant: nil) if q.blank?
|
|
172
|
+
scope.where("app_tenant LIKE ?", "%#{TelemetryRecord.sanitize_sql_like(q.to_s)}%")
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def self.sorted(rows, sort, dir)
|
|
176
|
+
key = SORTS.fetch(sort.to_s, :requests)
|
|
177
|
+
sign = dir.to_s == "asc" ? 1 : -1
|
|
178
|
+
rows.sort_by { |row| row[key] * sign }.first(MAX_TENANTS)
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# The 95th percentile as the duration `count * 5%` rows from the slowest
|
|
182
|
+
# end -- one indexed query, no percentile function needed.
|
|
183
|
+
def self.p95_ms(scope, count)
|
|
184
|
+
return 0.0 if count.to_i.zero?
|
|
185
|
+
ms(scope.order(duration: :desc).offset((count * 0.05).floor).limit(1).pluck(:duration).first)
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def self.grouped_executions(scope, tenant, from, to, error_sql, error_key, limit)
|
|
189
|
+
scope.between(from, to).where(app_tenant: tenant).group(:group_hash, :name)
|
|
190
|
+
.pluck(:group_hash, :name, COUNT_SQL, error_sql, Arel.sql("AVG(duration)"), Arel.sql("MAX(duration)"))
|
|
191
|
+
.map { |group_hash, name, count, errors, avg, max|
|
|
192
|
+
{ group_hash: group_hash, name: name, count: count, error_key => errors, avg: ms(avg), max: ms(max) }
|
|
193
|
+
}.sort_by { |row| -row[:count] }.first(limit)
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# Microseconds on the wire, milliseconds in every prop.
|
|
197
|
+
def self.ms(duration)
|
|
198
|
+
duration ? (duration / 1000.0).round(2) : 0.0
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def self.bucket_width(from, to, buckets)
|
|
202
|
+
[ (to - from) / buckets, 1.0 ].max
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# Bucket index per row: epoch seconds since `from` over the bucket width.
|
|
206
|
+
# Windows are an hour or more, so second resolution is plenty.
|
|
207
|
+
# Both interpolations are coerced to numbers first (`from` is a Time, `width`
|
|
208
|
+
# a Float from bucket_width), so the fragment can only ever contain two
|
|
209
|
+
# numeric literals -- never a caller-supplied string.
|
|
210
|
+
def self.bucket_sql(from, width)
|
|
211
|
+
Arel.sql("CAST((strftime('%s', occurred_at) - #{Integer(from.to_i)}) / #{Float(width)} AS INTEGER)")
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# A row landing exactly on `to` indexes one bucket past the end; clamp it
|
|
215
|
+
# into the last one.
|
|
216
|
+
def self.bucket_at(from, width, index, buckets)
|
|
217
|
+
from + ([ index.to_i, buckets - 1 ].min * width)
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Railwatch
|
|
4
|
+
module Telemetry
|
|
5
|
+
class Visit < TelemetryRecord
|
|
6
|
+
include Child
|
|
7
|
+
|
|
8
|
+
# Core Web Vitals reported by the browser client, with Google's
|
|
9
|
+
# [good, poor] thresholds: at or below good is "good", above poor is
|
|
10
|
+
# "poor". lcp/inp/ttfb are milliseconds, cls is unitless.
|
|
11
|
+
VITALS = { lcp: [ 2500, 4000 ], cls: [ 0.1, 0.25 ], inp: [ 200, 500 ], ttfb: [ 800, 1800 ] }.freeze
|
|
12
|
+
|
|
13
|
+
def self.rating(metric, value)
|
|
14
|
+
good, poor = VITALS.fetch(metric)
|
|
15
|
+
return "good" if value <= good
|
|
16
|
+
(value > poor) ? "poor" : "needs-improvement"
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def timeline_label
|
|
20
|
+
component.to_s.first(120)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Railwatch
|
|
4
|
+
# Base class for everything the engine stores about the host application.
|
|
5
|
+
# The hosted platform keeps one SQLite file per monitored environment through
|
|
6
|
+
# activerecord-tenanted; an embedded install monitors exactly one application,
|
|
7
|
+
# so this is a plain second database (`railwatch_telemetry` in the host's
|
|
8
|
+
# database.yml), separate from the host's own primary and from the engine's
|
|
9
|
+
# meta tables. Every telemetry query still runs inside
|
|
10
|
+
# Environment#with_telemetry so the code path matches the platform's.
|
|
11
|
+
class TelemetryRecord < ActiveRecord::Base
|
|
12
|
+
self.abstract_class = true
|
|
13
|
+
begin
|
|
14
|
+
connects_to database: { writing: :railwatch_telemetry, reading: :railwatch_telemetry }
|
|
15
|
+
rescue ActiveRecord::AdapterNotSpecified, LoadError
|
|
16
|
+
# No `railwatch_telemetry` entry in this environment's database.yml, or an entry
|
|
17
|
+
# whose adapter gem is not in the bundle yet (LoadError). A cloud-transport
|
|
18
|
+
# app has none and still eager-loads this class in production, and so
|
|
19
|
+
# does the --local installer's own boot, before it has written the
|
|
20
|
+
# entry -- so loading must not raise. Using it must, though: without
|
|
21
|
+
# connects_to this class would inherit ActiveRecord::Base's PRIMARY
|
|
22
|
+
# connection, and its tables are unprefixed, so a query would read and
|
|
23
|
+
# a write would corrupt the host application's own tables. Every route
|
|
24
|
+
# into the connection goes through connection_pool, so refusing here
|
|
25
|
+
# fails closed for reads and writes alike.
|
|
26
|
+
def self.connection_pool
|
|
27
|
+
raise Railwatch::DatabaseNotConfigured,
|
|
28
|
+
"the `railwatch_telemetry` database (everything the app reports) is not configured for the " \
|
|
29
|
+
"#{Rails.env} environment; run `bin/rails generate railwatch:install --local` " \
|
|
30
|
+
"or add it to config/database.yml (docs/embedded.md)"
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Records arrive as the gem's wire hashes; this is the shared envelope.
|
|
35
|
+
def self.envelope_columns(t)
|
|
36
|
+
t.datetime :occurred_at, null: false, precision: 6
|
|
37
|
+
t.string :deploy, limit: 128
|
|
38
|
+
t.string :server, limit: 255
|
|
39
|
+
t.string :group_hash, limit: 32
|
|
40
|
+
t.string :trace_id, limit: 36
|
|
41
|
+
t.string :execution_source, limit: 20
|
|
42
|
+
t.string :execution_id, limit: 36
|
|
43
|
+
t.string :execution_preview, limit: 255
|
|
44
|
+
t.string :execution_stage, limit: 32
|
|
45
|
+
t.string :user_ref, limit: 255
|
|
46
|
+
t.string :app_tenant, limit: 255
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Platform parity: callers wrap telemetry work in with_tenant. There is one
|
|
50
|
+
# tenant here, so it is just the block.
|
|
51
|
+
def self.with_tenant(_slug)
|
|
52
|
+
yield
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def self.tenant_exist?(_slug) = true
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Railwatch
|
|
4
|
+
# A performance rule: "requests to * must keep p95 under 2000 ms over 5
|
|
5
|
+
# minutes". Evaluated by DetectPerformanceIssuesJob against rollups.
|
|
6
|
+
class Threshold < ApplicationRecord
|
|
7
|
+
self.table_name = "railwatch_thresholds"
|
|
8
|
+
MAX_LIMIT = 1_000_000_000
|
|
9
|
+
MAX_WINDOW_MINUTES = 1_440
|
|
10
|
+
TARGET_KINDS = %w[requests jobs commands queries scheduled_tasks outgoing_requests].freeze
|
|
11
|
+
METRICS = %w[p95 max avg error_rate failure_rate missed].freeze
|
|
12
|
+
|
|
13
|
+
def environment = Environment.current
|
|
14
|
+
|
|
15
|
+
validates :target_kind, inclusion: { in: TARGET_KINDS }
|
|
16
|
+
validates :metric, inclusion: { in: METRICS }
|
|
17
|
+
validates :limit, numericality: { greater_than: 0, less_than_or_equal_to: MAX_LIMIT }
|
|
18
|
+
validates :window_minutes, numericality: { only_integer: true, greater_than: 0,
|
|
19
|
+
less_than_or_equal_to: MAX_WINDOW_MINUTES }
|
|
20
|
+
validates :target, presence: true, length: { maximum: 256 }
|
|
21
|
+
validates :limit, numericality: { less_than_or_equal_to: 100 }, if: -> { metric&.end_with?("rate") }
|
|
22
|
+
validates :target, uniqueness: { scope: %i[environment_id target_kind metric] }
|
|
23
|
+
|
|
24
|
+
def description
|
|
25
|
+
unit = metric.end_with?("rate") ? "%" : "ms"
|
|
26
|
+
"#{target_kind} #{target == '*' ? 'all' : target}: #{metric} over #{limit}#{unit} in #{window_minutes}m"
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Railwatch
|
|
4
|
+
# Who is looking at the dashboard. The platform has accounts and memberships;
|
|
5
|
+
# an embedded install sits behind the host's own authentication, so this is
|
|
6
|
+
# whatever the host says it is (Railwatch.config.dashboard_user), defaulting
|
|
7
|
+
# to a single anonymous operator. Comments, saved views and issue activity
|
|
8
|
+
# record this id.
|
|
9
|
+
class User
|
|
10
|
+
ID = 1
|
|
11
|
+
|
|
12
|
+
attr_reader :id, :name, :email
|
|
13
|
+
|
|
14
|
+
def self.current = Viewer.user
|
|
15
|
+
def self.find(id) = find_by(id: id) || raise(ActiveRecord::RecordNotFound, "User #{id}")
|
|
16
|
+
|
|
17
|
+
# A host's dashboard_user resolver hands back its own ids, and those ids
|
|
18
|
+
# are what comments, saved views and issue activity store. Resolving only
|
|
19
|
+
# ID meant a comment written by user 7 had no author, and assigning an
|
|
20
|
+
# issue to them raised RecordNotFound. There is no user table to look
|
|
21
|
+
# anyone up in, so: the viewer when it is them, the anonymous operator
|
|
22
|
+
# for the default id, and a bare identity carrying the stored id
|
|
23
|
+
# otherwise, which is everything that is actually known about them.
|
|
24
|
+
def self.find_by(id:)
|
|
25
|
+
return nil if id.nil? || id.to_s.empty?
|
|
26
|
+
viewer = Viewer.user
|
|
27
|
+
return viewer if viewer && viewer.id.to_s == id.to_s
|
|
28
|
+
return default if id.to_i == ID
|
|
29
|
+
|
|
30
|
+
new(id: id, name: "Operator", email: nil)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def self.default = @default ||= new(id: ID, name: "Operator", email: nil)
|
|
34
|
+
|
|
35
|
+
def initialize(id:, name:, email: nil)
|
|
36
|
+
@id = id
|
|
37
|
+
@name = name
|
|
38
|
+
@email = email
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def as_json(*) = { id: id, name: name, email: email, provider: nil, verified: true, editor: "vscode", editor_root: nil }
|
|
42
|
+
def accounts = [ Railwatch::Embedded::Account ]
|
|
43
|
+
def ==(other) = other.is_a?(User) && other.id == id
|
|
44
|
+
alias eql? ==
|
|
45
|
+
def hash = id.hash
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Railwatch
|
|
4
|
+
# Who is looking at the dashboard, request-scoped. The platform's code
|
|
5
|
+
# reads Current.user; the gem's own Railwatch::Current is the running
|
|
6
|
+
# execution, so this has a different name. Set from the host's
|
|
7
|
+
# dashboard_user resolver on every dashboard request.
|
|
8
|
+
class Viewer < ActiveSupport::CurrentAttributes
|
|
9
|
+
attribute :user, :environment, :account
|
|
10
|
+
|
|
11
|
+
def account = Railwatch::Embedded::Account
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html>
|
|
3
|
+
<head>
|
|
4
|
+
<title inertia><%= content_for(:title) || "Railwatch" %></title>
|
|
5
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
6
|
+
<meta name="application-name" content="Railwatch">
|
|
7
|
+
<meta name="railwatch-mount" content="<%= request.script_name %>">
|
|
8
|
+
<link rel="icon" href="/railwatch/icon.svg" type="image/svg+xml">
|
|
9
|
+
<link rel="apple-touch-icon" href="/railwatch/icon.png">
|
|
10
|
+
<%= csrf_meta_tags %>
|
|
11
|
+
<%= csp_meta_tag %>
|
|
12
|
+
<script nonce="<%= content_security_policy_nonce %>">
|
|
13
|
+
document.documentElement.classList.toggle(
|
|
14
|
+
"dark",
|
|
15
|
+
localStorage.appearance === "dark" ||
|
|
16
|
+
(!("appearance" in localStorage) && window.matchMedia("(prefers-color-scheme: dark)").matches),
|
|
17
|
+
);
|
|
18
|
+
</script>
|
|
19
|
+
<%= railwatch_font_preloads %>
|
|
20
|
+
<%= railwatch_asset_tags(nonce: content_security_policy_nonce) %>
|
|
21
|
+
</head>
|
|
22
|
+
<body>
|
|
23
|
+
<%= yield %>
|
|
24
|
+
</body>
|
|
25
|
+
</html>
|
data/config/routes.rb
CHANGED
|
@@ -2,4 +2,63 @@
|
|
|
2
2
|
|
|
3
3
|
Railwatch::Engine.routes.draw do
|
|
4
4
|
post "beacon", to: "beacon#create"
|
|
5
|
+
|
|
6
|
+
# The embedded dashboard. Same URL shape as the hosted platform so the
|
|
7
|
+
# prebuilt bundle's route helpers resolve unchanged once js-routes knows
|
|
8
|
+
# the mount point; a singleton install always answers for app 1, env 1.
|
|
9
|
+
root to: redirect { |_p, req| "#{req.script_name}/apps/1/envs/1" }
|
|
10
|
+
get "dashboard", to: redirect { |_p, req| "#{req.script_name}/apps/1/envs/1" }
|
|
11
|
+
get "docs", to: redirect("https://railwatch.rebulk.com/docs")
|
|
12
|
+
|
|
13
|
+
scope "apps/:application_id", as: :application do
|
|
14
|
+
get "/", to: redirect { |p, req| "#{req.script_name}/apps/#{p[:application_id]}/envs/1" }
|
|
15
|
+
scope "envs/:environment_id", as: :environment do
|
|
16
|
+
get "/", to: "overview#show", as: :overview
|
|
17
|
+
resources :requests, only: [ :index, :show ] do
|
|
18
|
+
get "routes/:group_hash", to: "requests#route", on: :collection, as: :route
|
|
19
|
+
end
|
|
20
|
+
resources :jobs, only: [ :index, :show ] do
|
|
21
|
+
get "classes/:group_hash", to: "jobs#klass", on: :collection, as: :klass
|
|
22
|
+
end
|
|
23
|
+
resources :scheduled_tasks, only: [ :index, :show ]
|
|
24
|
+
resources :commands, only: [ :index, :show ]
|
|
25
|
+
resources :executions, only: [ :show ]
|
|
26
|
+
resources :queries, only: [ :index, :show ]
|
|
27
|
+
resources :spans, only: [ :index, :show ]
|
|
28
|
+
resources :profiles, only: [ :index, :show ]
|
|
29
|
+
resources :attachments, only: [ :show ]
|
|
30
|
+
get "traces/:trace_id", to: "traces#show", as: :trace
|
|
31
|
+
resources :view_renders, path: "views", only: [ :index ]
|
|
32
|
+
resources :transactions, only: [ :index ]
|
|
33
|
+
resources :deprecations, only: [ :index ]
|
|
34
|
+
resources :exceptions, only: [ :index ]
|
|
35
|
+
resources :cache_events, path: "cache", only: [ :index ]
|
|
36
|
+
resources :mails, path: "mail", only: [ :index ]
|
|
37
|
+
resources :notifications, only: [ :index ]
|
|
38
|
+
resources :broadcasts, only: [ :index ]
|
|
39
|
+
resources :outgoing_requests, only: [ :index ]
|
|
40
|
+
resources :llm_calls, path: "llm", only: [ :index ]
|
|
41
|
+
resources :storage_ops, path: "storage", only: [ :index ]
|
|
42
|
+
resources :logs, only: [ :index ]
|
|
43
|
+
resources :people, path: "users", only: [ :index, :show ]
|
|
44
|
+
resources :deploys, only: [ :index, :show ]
|
|
45
|
+
resources :releases, only: [ :index, :show ]
|
|
46
|
+
resources :visits, only: [ :index ]
|
|
47
|
+
resources :processes, only: [ :index ]
|
|
48
|
+
resources :tenants, only: [ :index, :show ]
|
|
49
|
+
resources :saved_views, only: [ :create, :update, :destroy ]
|
|
50
|
+
resources :thresholds, only: [ :index, :create, :update, :destroy ]
|
|
51
|
+
resources :anomaly_rules, only: [ :create, :update, :destroy ]
|
|
52
|
+
resources :alerts, only: [ :index ]
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
resources :issues, only: [ :index, :show, :update ] do
|
|
57
|
+
resources :comments, only: [ :create ]
|
|
58
|
+
post :bulk, on: :collection
|
|
59
|
+
get :merge_candidates, on: :member
|
|
60
|
+
end
|
|
61
|
+
resources :alerts, only: [ :index ] do
|
|
62
|
+
post :retry, on: :member
|
|
63
|
+
end
|
|
5
64
|
end
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# The engine's own record of the monitored application: issues and what
|
|
4
|
+
# people did about them, saved views, thresholds, anomaly and alert rules,
|
|
5
|
+
# deploy markers. Authored and permanent where telemetry is derived and
|
|
6
|
+
# pruned, so it lives in the host's `railwatch` database, never its primary.
|
|
7
|
+
#
|
|
8
|
+
# Nothing here references the host's tables. `viewer_id` on comments,
|
|
9
|
+
# activity and saved views is whatever the host's dashboard_user resolver
|
|
10
|
+
# returned as `id`, stored as an integer and never joined; `assignee_id`
|
|
11
|
+
# on issues is the same. application_id and environment_id are always 1 in
|
|
12
|
+
# an embedded install and exist so the dashboard's URLs and the hosted
|
|
13
|
+
# platform's code stay identical.
|
|
14
|
+
class CreateRailwatchTables < ActiveRecord::Migration[8.1]
|
|
15
|
+
def change
|
|
16
|
+
create_table :railwatch_issues do |t|
|
|
17
|
+
t.integer :application_id, null: false
|
|
18
|
+
t.integer :environment_id, null: false
|
|
19
|
+
t.integer :number, null: false
|
|
20
|
+
t.string :group_hash, limit: 32, null: false
|
|
21
|
+
t.string :kind, default: "exception", null: false
|
|
22
|
+
t.string :title, null: false
|
|
23
|
+
t.string :culprit
|
|
24
|
+
t.string :source, limit: 128
|
|
25
|
+
t.string :status, default: "open", null: false
|
|
26
|
+
t.string :priority, default: "normal", null: false
|
|
27
|
+
t.integer :assignee_id
|
|
28
|
+
t.integer :merged_into_id
|
|
29
|
+
t.bigint :occurrences, default: 0, null: false
|
|
30
|
+
t.integer :affected_users, default: 0, null: false
|
|
31
|
+
t.datetime :first_seen_at, null: false
|
|
32
|
+
t.datetime :last_seen_at, null: false
|
|
33
|
+
t.datetime :resolved_at
|
|
34
|
+
t.string :resolved_in_deploy, limit: 128
|
|
35
|
+
t.datetime :regressed_at
|
|
36
|
+
t.json :sample, default: {}, null: false
|
|
37
|
+
t.timestamps
|
|
38
|
+
end
|
|
39
|
+
add_index :railwatch_issues, [ :application_id, :number ], unique: true
|
|
40
|
+
add_index :railwatch_issues, [ :environment_id, :group_hash ], unique: true
|
|
41
|
+
add_index :railwatch_issues, [ :environment_id, :status, :last_seen_at ]
|
|
42
|
+
add_index :railwatch_issues, :merged_into_id
|
|
43
|
+
|
|
44
|
+
create_table :railwatch_comments do |t|
|
|
45
|
+
t.integer :issue_id, null: false
|
|
46
|
+
t.integer :viewer_id
|
|
47
|
+
t.string :author_name
|
|
48
|
+
t.text :body, null: false
|
|
49
|
+
t.string :source, default: "railwatch", null: false
|
|
50
|
+
t.string :external_id
|
|
51
|
+
t.datetime :external_updated_at
|
|
52
|
+
t.timestamps
|
|
53
|
+
end
|
|
54
|
+
add_index :railwatch_comments, :issue_id
|
|
55
|
+
|
|
56
|
+
create_table :railwatch_issue_activities do |t|
|
|
57
|
+
t.integer :issue_id, null: false
|
|
58
|
+
t.integer :viewer_id
|
|
59
|
+
t.string :kind, null: false
|
|
60
|
+
t.json :data, default: {}, null: false
|
|
61
|
+
t.datetime :created_at, null: false
|
|
62
|
+
end
|
|
63
|
+
add_index :railwatch_issue_activities, [ :issue_id, :created_at ]
|
|
64
|
+
|
|
65
|
+
create_table :railwatch_deploys do |t|
|
|
66
|
+
t.integer :environment_id, null: false
|
|
67
|
+
t.string :deploy, limit: 128, null: false
|
|
68
|
+
t.string :ref, limit: 128
|
|
69
|
+
t.string :previous_ref, limit: 128
|
|
70
|
+
t.string :name
|
|
71
|
+
t.string :url
|
|
72
|
+
t.string :server
|
|
73
|
+
t.datetime :deployed_at, null: false
|
|
74
|
+
t.json :commits, default: [], null: false
|
|
75
|
+
t.json :detail, default: {}, null: false
|
|
76
|
+
t.timestamps
|
|
77
|
+
end
|
|
78
|
+
add_index :railwatch_deploys, [ :environment_id, :deploy ], unique: true
|
|
79
|
+
add_index :railwatch_deploys, [ :environment_id, :deployed_at ]
|
|
80
|
+
|
|
81
|
+
create_table :railwatch_saved_views do |t|
|
|
82
|
+
t.integer :environment_id, null: false
|
|
83
|
+
t.integer :viewer_id, null: false
|
|
84
|
+
t.string :name, null: false
|
|
85
|
+
t.string :page, null: false
|
|
86
|
+
t.string :query
|
|
87
|
+
t.string :window, limit: 8
|
|
88
|
+
t.json :params, default: {}, null: false
|
|
89
|
+
t.boolean :pinned, default: true, null: false
|
|
90
|
+
t.boolean :shared, default: true, null: false
|
|
91
|
+
t.timestamps
|
|
92
|
+
end
|
|
93
|
+
add_index :railwatch_saved_views, [ :environment_id, :page ]
|
|
94
|
+
|
|
95
|
+
create_table :railwatch_thresholds do |t|
|
|
96
|
+
t.integer :environment_id, null: false
|
|
97
|
+
t.string :target_kind, null: false
|
|
98
|
+
t.string :target, default: "*", null: false
|
|
99
|
+
t.string :metric, default: "p95", null: false
|
|
100
|
+
t.float :limit, null: false
|
|
101
|
+
t.integer :window_minutes, default: 5, null: false
|
|
102
|
+
t.timestamps
|
|
103
|
+
end
|
|
104
|
+
add_index :railwatch_thresholds, [ :environment_id, :target_kind, :target, :metric ], unique: true, name: "index_railwatch_thresholds_unique"
|
|
105
|
+
|
|
106
|
+
create_table :railwatch_anomaly_rules do |t|
|
|
107
|
+
t.integer :environment_id, null: false
|
|
108
|
+
t.string :target_kind, null: false
|
|
109
|
+
t.string :target, default: "*", null: false
|
|
110
|
+
t.string :metric, null: false
|
|
111
|
+
t.float :deviation, default: 3.0, null: false
|
|
112
|
+
t.integer :baseline_days, default: 7, null: false
|
|
113
|
+
t.integer :window_minutes, default: 15, null: false
|
|
114
|
+
t.boolean :enabled, default: true, null: false
|
|
115
|
+
t.datetime :last_fired_at
|
|
116
|
+
t.timestamps
|
|
117
|
+
end
|
|
118
|
+
add_index :railwatch_anomaly_rules, :environment_id
|
|
119
|
+
|
|
120
|
+
create_table :railwatch_alert_rules do |t|
|
|
121
|
+
t.integer :application_id, null: false
|
|
122
|
+
t.string :event, null: false
|
|
123
|
+
t.json :filters, default: {}, null: false
|
|
124
|
+
t.timestamps
|
|
125
|
+
end
|
|
126
|
+
add_index :railwatch_alert_rules, :application_id
|
|
127
|
+
|
|
128
|
+
create_table :railwatch_alerts do |t|
|
|
129
|
+
t.integer :alert_rule_id
|
|
130
|
+
t.integer :issue_id
|
|
131
|
+
t.integer :summary_alert_id
|
|
132
|
+
t.string :event, null: false
|
|
133
|
+
t.string :status, default: "pending", null: false
|
|
134
|
+
t.json :payload, default: {}, null: false
|
|
135
|
+
t.text :error
|
|
136
|
+
t.datetime :sent_at
|
|
137
|
+
t.string :burst_key
|
|
138
|
+
t.string :delivery_key
|
|
139
|
+
t.integer :delivery_attempts, default: 0, null: false
|
|
140
|
+
t.datetime :next_delivery_at
|
|
141
|
+
t.datetime :delivery_enqueued_until
|
|
142
|
+
t.string :delivery_lease_id
|
|
143
|
+
t.datetime :delivery_lease_expires_at
|
|
144
|
+
t.timestamps
|
|
145
|
+
end
|
|
146
|
+
add_index :railwatch_alerts, :burst_key, unique: true
|
|
147
|
+
add_index :railwatch_alerts, :delivery_key, unique: true
|
|
148
|
+
add_index :railwatch_alerts, [ :issue_id, :event, :created_at ]
|
|
149
|
+
add_index :railwatch_alerts, [ :status, :created_at, :alert_rule_id ], name: "index_railwatch_alerts_for_burst_reconciliation"
|
|
150
|
+
end
|
|
151
|
+
end
|