railwatch 0.1.3 → 0.2.0.pre1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +231 -0
- data/README.md +10 -0
- data/app/channels/railwatch/environment_channel.rb +28 -0
- data/app/controllers/concerns/railwatch/telemetry_identity.rb +25 -0
- data/app/controllers/railwatch/alerts_controller.rb +48 -0
- data/app/controllers/railwatch/anomaly_rules_controller.rb +31 -0
- data/app/controllers/railwatch/attachments_controller.rb +22 -0
- data/app/controllers/railwatch/beacon_controller.rb +67 -7
- data/app/controllers/railwatch/broadcasts_controller.rb +15 -0
- data/app/controllers/railwatch/cache_events_controller.rb +30 -0
- data/app/controllers/railwatch/commands_controller.rb +16 -0
- data/app/controllers/railwatch/comments_controller.rb +11 -0
- data/app/controllers/railwatch/dashboard_controller.rb +69 -0
- data/app/controllers/railwatch/deploys_controller.rb +34 -0
- data/app/controllers/railwatch/deprecations_controller.rb +54 -0
- data/app/controllers/railwatch/environment_scoped.rb +99 -0
- data/app/controllers/railwatch/exceptions_controller.rb +53 -0
- data/app/controllers/railwatch/executions_controller.rb +13 -0
- data/app/controllers/railwatch/issues_controller.rb +258 -0
- data/app/controllers/railwatch/jobs_controller.rb +63 -0
- data/app/controllers/railwatch/llm_calls_controller.rb +124 -0
- data/app/controllers/railwatch/logs_controller.rb +40 -0
- data/app/controllers/railwatch/mails_controller.rb +19 -0
- data/app/controllers/railwatch/notifications_controller.rb +11 -0
- data/app/controllers/railwatch/outgoing_requests_controller.rb +19 -0
- data/app/controllers/railwatch/overview_controller.rb +38 -0
- data/app/controllers/railwatch/people_controller.rb +25 -0
- data/app/controllers/railwatch/processes_controller.rb +33 -0
- data/app/controllers/railwatch/profiles_controller.rb +50 -0
- data/app/controllers/railwatch/queries_controller.rb +59 -0
- data/app/controllers/railwatch/releases_controller.rb +75 -0
- data/app/controllers/railwatch/requests_controller.rb +55 -0
- data/app/controllers/railwatch/saved_views_controller.rb +53 -0
- data/app/controllers/railwatch/scheduled_tasks_controller.rb +45 -0
- data/app/controllers/railwatch/spans_controller.rb +47 -0
- data/app/controllers/railwatch/storage_ops_controller.rb +15 -0
- data/app/controllers/railwatch/tenants_controller.rb +44 -0
- data/app/controllers/railwatch/thresholds_controller.rb +40 -0
- data/app/controllers/railwatch/traces_controller.rb +72 -0
- data/app/controllers/railwatch/transactions_controller.rb +21 -0
- data/app/controllers/railwatch/view_renders_controller.rb +28 -0
- data/app/controllers/railwatch/visits_controller.rb +53 -0
- data/app/helpers/railwatch/assets_helper.rb +52 -0
- data/app/jobs/railwatch/anomaly_scan_job.rb +11 -0
- data/app/jobs/railwatch/application_job.rb +7 -0
- data/app/jobs/railwatch/auto_resolve_issues_job.rb +19 -0
- data/app/jobs/railwatch/check_scheduled_tasks_job.rb +113 -0
- data/app/jobs/railwatch/detect_anomalies_job.rb +171 -0
- data/app/jobs/railwatch/detect_performance_issues_job.rb +85 -0
- data/app/jobs/railwatch/group_exceptions_job.rb +91 -0
- data/app/jobs/railwatch/optimize_telemetry_job.rb +23 -0
- data/app/jobs/railwatch/performance_scan_job.rb +11 -0
- data/app/jobs/railwatch/prune_telemetry_job.rb +110 -0
- data/app/jobs/railwatch/release_health_rollup_job.rb +64 -0
- data/app/jobs/railwatch/rollup_catchup_job.rb +21 -0
- data/app/jobs/railwatch/rollup_job.rb +130 -0
- data/app/jobs/railwatch/scheduled_task_scan_job.rb +11 -0
- data/app/models/concerns/railwatch/detection_snapshotting.rb +20 -0
- data/app/models/railwatch/alert.rb +229 -0
- data/app/models/railwatch/alert_rule.rb +121 -0
- data/app/models/railwatch/anomaly_rule.rb +33 -0
- data/app/models/railwatch/application.rb +44 -0
- data/app/models/railwatch/application_record.rb +33 -0
- data/app/models/railwatch/comment.rb +36 -0
- data/app/models/railwatch/deploy.rb +67 -0
- data/app/models/railwatch/environment.rb +54 -0
- data/app/models/railwatch/execution_presenter.rb +185 -0
- data/app/models/railwatch/filter_query.rb +143 -0
- data/app/models/railwatch/followup_receipt.rb +27 -0
- data/app/models/railwatch/ingest/batch.rb +305 -0
- data/app/models/railwatch/ingest/mapper.rb +575 -0
- data/app/models/railwatch/ingest/payload.rb +96 -0
- data/app/models/railwatch/ingest/rollup_absorber.rb +170 -0
- data/app/models/railwatch/ingest/writer.rb +137 -0
- data/app/models/railwatch/issue.rb +277 -0
- data/app/models/railwatch/issue_activity.rb +23 -0
- data/app/models/railwatch/issue_detection_presenter.rb +309 -0
- data/app/models/railwatch/issue_detection_snapshot.rb +77 -0
- data/app/models/railwatch/maintenance_task.rb +53 -0
- data/app/models/railwatch/saved_view.rb +63 -0
- data/app/models/railwatch/telemetry/aggregations.rb +116 -0
- data/app/models/railwatch/telemetry/attachment.rb +31 -0
- data/app/models/railwatch/telemetry/bounded_gzip.rb +101 -0
- data/app/models/railwatch/telemetry/broadcast.rb +13 -0
- data/app/models/railwatch/telemetry/cache_event.rb +16 -0
- data/app/models/railwatch/telemetry/child.rb +53 -0
- data/app/models/railwatch/telemetry/cursor_page.rb +99 -0
- data/app/models/railwatch/telemetry/deprecation.rb +13 -0
- data/app/models/railwatch/telemetry/enqueued_job.rb +13 -0
- data/app/models/railwatch/telemetry/exception.rb +21 -0
- data/app/models/railwatch/telemetry/execution.rb +71 -0
- data/app/models/railwatch/telemetry/health_sample.rb +72 -0
- data/app/models/railwatch/telemetry/ingest_batch.rb +31 -0
- data/app/models/railwatch/telemetry/llm_call.rb +37 -0
- data/app/models/railwatch/telemetry/log.rb +85 -0
- data/app/models/railwatch/telemetry/mail.rb +13 -0
- data/app/models/railwatch/telemetry/n_plus_one.rb +92 -0
- data/app/models/railwatch/telemetry/notification.rb +13 -0
- data/app/models/railwatch/telemetry/outgoing_request.rb +13 -0
- data/app/models/railwatch/telemetry/person.rb +50 -0
- data/app/models/railwatch/telemetry/process.rb +10 -0
- data/app/models/railwatch/telemetry/profile.rb +37 -0
- data/app/models/railwatch/telemetry/query.rb +50 -0
- data/app/models/railwatch/telemetry/query_shape.rb +45 -0
- data/app/models/railwatch/telemetry/release_health.rb +63 -0
- data/app/models/railwatch/telemetry/rollup.rb +78 -0
- data/app/models/railwatch/telemetry/session.rb +24 -0
- data/app/models/railwatch/telemetry/span.rb +19 -0
- data/app/models/railwatch/telemetry/storage_op.rb +13 -0
- data/app/models/railwatch/telemetry/tenant.rb +221 -0
- data/app/models/railwatch/telemetry/transaction.rb +13 -0
- data/app/models/railwatch/telemetry/view_render.rb +13 -0
- data/app/models/railwatch/telemetry/visit.rb +24 -0
- data/app/models/railwatch/telemetry_record.rb +57 -0
- data/app/models/railwatch/threshold.rb +29 -0
- data/app/models/railwatch/user.rb +47 -0
- data/app/models/railwatch/viewer.rb +13 -0
- data/app/views/layouts/railwatch/dashboard.html.erb +25 -0
- data/config/routes.rb +59 -0
- data/db/railwatch_migrate/20260916000000_create_railwatch_tables.rb +151 -0
- data/db/railwatch_migrate/20260917000000_create_railwatch_maintenance_tasks.rb +18 -0
- data/db/railwatch_migrate/20260917120000_create_railwatch_followup_receipts.rb +20 -0
- data/db/railwatch_telemetry_migrate/20260903000001_create_telemetry.rb +481 -0
- data/db/railwatch_telemetry_migrate/20260903000002_rename_tenant_to_app_tenant.rb +14 -0
- data/db/railwatch_telemetry_migrate/20260903000003_add_statement_count_to_transactions.rb +9 -0
- data/db/railwatch_telemetry_migrate/20260903000004_add_role_and_channel.rb +10 -0
- data/db/railwatch_telemetry_migrate/20260903000005_add_locals_to_exceptions.rb +9 -0
- data/db/railwatch_telemetry_migrate/20260903000006_add_spans_health_vitals_and_fts.rb +82 -0
- data/db/railwatch_telemetry_migrate/20260903000007_rename_span_attributes_to_payload.rb +10 -0
- data/db/railwatch_telemetry_migrate/20260903000008_add_profiles_and_attachments.rb +60 -0
- data/db/railwatch_telemetry_migrate/20260903000009_add_truncated_to_attachments.rb +9 -0
- data/db/railwatch_telemetry_migrate/20260903000010_create_sessions_and_release_health.rb +51 -0
- data/db/railwatch_telemetry_migrate/20260903000011_add_fingerprint_to_exceptions.rb +11 -0
- data/db/railwatch_telemetry_migrate/20260904000012_add_failed_to_broadcasts.rb +10 -0
- data/db/railwatch_telemetry_migrate/20260904010000_add_filter_cursor_indexes.rb +37 -0
- data/db/railwatch_telemetry_migrate/20260904020000_add_n_plus_ones_execution_id_index.rb +11 -0
- data/db/railwatch_telemetry_migrate/20260904120000_create_query_shapes.rb +15 -0
- data/db/railwatch_telemetry_migrate/20260906120000_add_backpressure_factor_to_ingest_batches.rb +9 -0
- data/db/railwatch_telemetry_migrate/20260907000000_rename_lantern_version_on_processes.rb +10 -0
- data/db/railwatch_telemetry_migrate/20260913000000_rename_nightrail_version_on_processes.rb +9 -0
- data/db/railwatch_telemetry_migrate/20260914000000_drop_orphan_durable_ingest_tables.rb +18 -0
- data/db/railwatch_telemetry_migrate/20260914010000_drop_orphan_durable_ingest_columns.rb +25 -0
- data/db/railwatch_telemetry_migrate/20260915000000_create_llm_calls.rb +55 -0
- data/db/railwatch_telemetry_migrate/20260915120000_add_detail_to_llm_calls.rb +21 -0
- data/db/railwatch_telemetry_migrate/20260915200000_add_explained_index_to_queries.rb +16 -0
- data/db/railwatch_telemetry_migrate/20260915210000_add_slowest_index_to_queries.rb +18 -0
- data/db/railwatch_telemetry_migrate/20260917010000_add_batch_ledger_to_ingest_batches.rb +16 -0
- data/docs/configuration.md +26 -0
- data/docs/embedded.md +342 -0
- data/docs/getting-started.md +5 -0
- data/docs/records.md +16 -1
- data/lib/generators/railwatch/install/install_generator.rb +222 -1
- data/lib/generators/railwatch/install/templates/{initializer.rb → initializer.rb.tt} +39 -0
- data/lib/generators/railwatch/install/templates/post-deploy +8 -0
- data/lib/puma/plugin/railwatch.rb +170 -0
- data/lib/railwatch/authentication.rb +83 -0
- data/lib/railwatch/configuration.rb +134 -3
- data/lib/railwatch/dashboard_assets.rb +45 -0
- data/lib/railwatch/embedded.rb +51 -0
- data/lib/railwatch/engine.rb +89 -1
- data/lib/railwatch/ingest_request_body_limit.rb +10 -0
- data/lib/railwatch/json_compat.rb +60 -0
- data/lib/railwatch/maintenance.rb +183 -0
- data/lib/railwatch/patches/runner_command.rb +21 -1
- data/lib/railwatch/record.rb +33 -8
- data/lib/railwatch/reporter.rb +16 -0
- data/lib/railwatch/subscribers/llm.rb +149 -4
- data/lib/railwatch/subscribers/process_info.rb +2 -1
- data/lib/railwatch/transport/local.rb +78 -0
- data/lib/railwatch/transport/socket.rb +183 -0
- data/lib/railwatch/version.rb +1 -1
- data/lib/railwatch/writer.rb +370 -0
- data/lib/railwatch.rb +38 -1
- data/lib/tasks/railwatch_tasks.rake +128 -0
- data/public/railwatch/assets/CommitMono-Bold-D6h61ieg.woff2 +0 -0
- data/public/railwatch/assets/CommitMono-Regular-zr8w7Obm.woff2 +0 -0
- data/public/railwatch/assets/Roboto-Black-auA4GeOK.woff2 +0 -0
- data/public/railwatch/assets/Roboto-Bold-CJLnO8j1.woff2 +0 -0
- data/public/railwatch/assets/Roboto-Medium-Cm2bwKpj.woff2 +0 -0
- data/public/railwatch/assets/Roboto-Regular-Chaq1-PV.woff2 +0 -0
- data/public/railwatch/assets/app-layout-yh-sPWgK.js +1 -0
- data/public/railwatch/assets/app-wordmark-nbjkzxwQ.js +1 -0
- data/public/railwatch/assets/appearance-CDuRvQTB.js +1 -0
- data/public/railwatch/assets/application-C_kpBdnf.css +1 -0
- data/public/railwatch/assets/arrow-up-DVtOGdVA.js +1 -0
- data/public/railwatch/assets/auth-layout-TCwPpS1K.js +1 -0
- data/public/railwatch/assets/badge-Daw4Hvr8.js +1 -0
- data/public/railwatch/assets/braces-DhFbHPsz.js +1 -0
- data/public/railwatch/assets/card-BX_3HXcJ.js +1 -0
- data/public/railwatch/assets/chart-B14-N9g7.js +39 -0
- data/public/railwatch/assets/chart-hover-Vy52H4uD.js +1 -0
- data/public/railwatch/assets/chart-panel-Cb4S_ej_.js +1 -0
- data/public/railwatch/assets/checkbox-D3aRSsBj.js +1 -0
- data/public/railwatch/assets/code-KvW8k7Jr.js +1 -0
- data/public/railwatch/assets/copy-DaQMJWoT.js +1 -0
- data/public/railwatch/assets/copy-block-BKFGd11J.js +1 -0
- data/public/railwatch/assets/copy-id-Djks1fXB.js +1 -0
- data/public/railwatch/assets/cursor-load-more-BXV0f1D_.js +1 -0
- data/public/railwatch/assets/data-table-iv1bdF6u.js +1 -0
- data/public/railwatch/assets/edit-BZc_Iawe.js +1 -0
- data/public/railwatch/assets/edit-DMKUF8Zi.js +8 -0
- data/public/railwatch/assets/edit-__9yJlO3.js +1 -0
- data/public/railwatch/assets/empty-state-6j_0AaQQ.js +1 -0
- data/public/railwatch/assets/env-layout-DrT8rO6P.js +1 -0
- data/public/railwatch/assets/execution-path-CzgBUi5e.js +1 -0
- data/public/railwatch/assets/filter-bar-9SU5NrzX.js +1 -0
- data/public/railwatch/assets/flamegraph-qcekju8V.js +2 -0
- data/public/railwatch/assets/format-B9SDkrWj.js +1 -0
- data/public/railwatch/assets/frames-Cyu7KMxZ.js +1 -0
- data/public/railwatch/assets/google-sign-in-button-DsTSfmzY.js +1 -0
- data/public/railwatch/assets/index-1ol1-QWI.js +1 -0
- data/public/railwatch/assets/index-5jI4aFzC.js +1 -0
- data/public/railwatch/assets/index-9KTrVnrc.js +1 -0
- data/public/railwatch/assets/index-B0-8lcTp.js +1 -0
- data/public/railwatch/assets/index-B7jjfNfO.js +1 -0
- data/public/railwatch/assets/index-BBchRy0M.js +1 -0
- data/public/railwatch/assets/index-BRiq3SNR.js +1 -0
- data/public/railwatch/assets/index-BgKj9xhr.js +1 -0
- data/public/railwatch/assets/index-BkTZqqOu.js +1 -0
- data/public/railwatch/assets/index-BprKx8QO.js +1 -0
- data/public/railwatch/assets/index-C3jzvPs3.js +1 -0
- data/public/railwatch/assets/index-Cbs6gGyQ.js +1 -0
- data/public/railwatch/assets/index-CdRZ6AWF.js +1 -0
- data/public/railwatch/assets/index-CeYKnapu.js +1 -0
- data/public/railwatch/assets/index-Cmlwy1-V.js +1 -0
- data/public/railwatch/assets/index-Cwx6058d.js +1 -0
- data/public/railwatch/assets/index-D4CSdbHv.js +1 -0
- data/public/railwatch/assets/index-DEFMSkdG.js +1 -0
- data/public/railwatch/assets/index-DFiHEBSh.js +1 -0
- data/public/railwatch/assets/index-DL4vWdWJ.js +1 -0
- data/public/railwatch/assets/index-DU9F5b5d.js +1 -0
- data/public/railwatch/assets/index-DaXgPcGL.js +1 -0
- data/public/railwatch/assets/index-DbtaU-EE.js +1 -0
- data/public/railwatch/assets/index-DeOe83F4.js +1 -0
- data/public/railwatch/assets/index-DiucHN4B.js +1 -0
- data/public/railwatch/assets/index-DlnR_l9o.js +1 -0
- data/public/railwatch/assets/index-DlumCsWY.js +2 -0
- data/public/railwatch/assets/index-DmRd7aIG.js +1 -0
- data/public/railwatch/assets/index-DxSh2UpM.js +1 -0
- data/public/railwatch/assets/index-MIMGuFNt.js +1 -0
- data/public/railwatch/assets/index-OqI59zPb.js +1 -0
- data/public/railwatch/assets/index-P4rC7IlX.js +1 -0
- data/public/railwatch/assets/index-gpPOcFWq.js +1 -0
- data/public/railwatch/assets/index-oVkururr.js +1 -0
- data/public/railwatch/assets/index-p9puqVge.js +1 -0
- data/public/railwatch/assets/inertia-TViv6kNv.js +97 -0
- data/public/railwatch/assets/input-error-LxImUkxv.js +1 -0
- data/public/railwatch/assets/json-viewer-Ar4cjPDW.js +1 -0
- data/public/railwatch/assets/klass-CJ-J4INB.js +1 -0
- data/public/railwatch/assets/label-COUKWqE_.js +1 -0
- data/public/railwatch/assets/layout-DNSLAkw_.js +1 -0
- data/public/railwatch/assets/live-dot-ChfUtY3p.js +41 -0
- data/public/railwatch/assets/nav-CNnDqPlm.js +1 -0
- data/public/railwatch/assets/new-84S8ZJq9.js +1 -0
- data/public/railwatch/assets/new-Be55nmt9.js +1 -0
- data/public/railwatch/assets/new-Bi_xQiIb.js +1 -0
- data/public/railwatch/assets/new-BvCT8TMg.js +1 -0
- data/public/railwatch/assets/new-D05SajFR.js +1 -0
- data/public/railwatch/assets/new-D4uewYC8.js +1 -0
- data/public/railwatch/assets/onboarding-CYZi5Cqc.js +1 -0
- data/public/railwatch/assets/origin-identity-6q1-CBts.js +1 -0
- data/public/railwatch/assets/percentile-picker-gFZCXtdb.js +1 -0
- data/public/railwatch/assets/relative-time-IOOgl5n2.js +1 -0
- data/public/railwatch/assets/release-health-DC8oc7uw.js +1 -0
- data/public/railwatch/assets/route-Dv6LAWvT.js +1 -0
- data/public/railwatch/assets/segmented-h1VdDTqE.js +1 -0
- data/public/railwatch/assets/select-_AJsUa7X.js +1 -0
- data/public/railwatch/assets/separator-BwwTYtCF.js +1 -0
- data/public/railwatch/assets/series-chart-DaFPefku.js +1 -0
- data/public/railwatch/assets/show-B7NCgkEo.js +1 -0
- data/public/railwatch/assets/show-BKqyKjBK.js +1 -0
- data/public/railwatch/assets/show-BM6X2Mpo.js +1 -0
- data/public/railwatch/assets/show-BNw4tN5q.js +1 -0
- data/public/railwatch/assets/show-BO3bnG5h.js +1 -0
- data/public/railwatch/assets/show-BhrAVAEA.js +1 -0
- data/public/railwatch/assets/show-C4Ltf5i9.js +2 -0
- data/public/railwatch/assets/show-C8sHalnw.js +1 -0
- data/public/railwatch/assets/show-CeTL4B37.js +2 -0
- data/public/railwatch/assets/show-CpfgV1jP.js +1 -0
- data/public/railwatch/assets/show-DACku6AD.js +3 -0
- data/public/railwatch/assets/show-DIOSGcXV.js +6 -0
- data/public/railwatch/assets/show-DQp_1n-B.js +1 -0
- data/public/railwatch/assets/show-DVNz46RI.js +1 -0
- data/public/railwatch/assets/show-DYteoYWW.js +1 -0
- data/public/railwatch/assets/show-DgSIoRvA.js +1 -0
- data/public/railwatch/assets/show-JxFtB4eK.js +2 -0
- data/public/railwatch/assets/sort-header-DpFzXblu.js +1 -0
- data/public/railwatch/assets/source-link-B2183i2-.js +1 -0
- data/public/railwatch/assets/sparkline-cell-C3-5vFkP.js +1 -0
- data/public/railwatch/assets/stat-s4RpOS9w.js +1 -0
- data/public/railwatch/assets/status-badge-8jVV-LA4.js +1 -0
- data/public/railwatch/assets/tenant-path-G-6u9A-o.js +1 -0
- data/public/railwatch/assets/text-link-DfsiaCcP.js +1 -0
- data/public/railwatch/assets/textarea-Dye72uP7.js +1 -0
- data/public/railwatch/assets/timeline-CD7WHnbo.js +1 -0
- data/public/railwatch/assets/transition-B_AW8rMK.js +5 -0
- data/public/railwatch/assets/use-clipboard-ColgLyQ2.js +1 -0
- data/public/railwatch/icon.png +0 -0
- data/public/railwatch/icon.svg +5 -0
- data/public/railwatch/manifest.json +2171 -0
- data/public/railwatch/rails-vite.json +1 -0
- metadata +313 -4
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Railwatch
|
|
4
|
+
class SpansController < DashboardController
|
|
5
|
+
def index
|
|
6
|
+
spans = grouped("span", limit: 200, order: params[:sort], dir: params[:dir])
|
|
7
|
+
spans = apply_filters(spans, FilterQuery.parse(params[:q]).fetch(:fields))
|
|
8
|
+
render inertia: { spans: spans, series: series("span"), summary: summary_with_delta("span"),
|
|
9
|
+
sort: params[:sort] || "count", dir: params[:dir] || "desc", q: params[:q].to_s }
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def show
|
|
13
|
+
group_hash = params[:id]
|
|
14
|
+
rows = telemetry { Telemetry::Span.where(group_hash: group_hash).between(*window_range).recent.limit(200).to_a }
|
|
15
|
+
render inertia: {
|
|
16
|
+
group_hash: group_hash,
|
|
17
|
+
name: rows.first&.name || telemetry { Telemetry::Rollup.for_type("span").where(group_hash: group_hash).pick(:name) },
|
|
18
|
+
summary: summary_with_delta("span", group_hash: group_hash), series: series("span", group_hash: group_hash),
|
|
19
|
+
facets: attribute_facets(rows), samples: rows.first(50).map { |s| span_row(s) }
|
|
20
|
+
}
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
# Rollup rows for "span" carry the span name and a failure count, so
|
|
26
|
+
# status:failed is "this group failed at least once in the window" rather
|
|
27
|
+
# than a per-span lookup.
|
|
28
|
+
def apply_filters(spans, fields)
|
|
29
|
+
spans = spans.select { |s| s[:name].to_s.include?(fields["name"]) } if fields["name"].present?
|
|
30
|
+
spans = spans.select { |s| s[:errors].positive? } if fields["status"] == "failed"
|
|
31
|
+
spans
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Which attribute keys these spans carry, and the most common values for
|
|
35
|
+
# each -- the span equivalent of the query page's "called from" breakdown.
|
|
36
|
+
def attribute_facets(rows)
|
|
37
|
+
rows.flat_map { |r| r.payload.to_a }.group_by(&:first).map { |key, pairs|
|
|
38
|
+
{ key: key, count: pairs.size, values: pairs.map { |_k, value| value.to_s }.tally.sort_by { |_v, c| -c }.first(5) }
|
|
39
|
+
}.sort_by { |facet| -facet[:count] }.first(10)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def span_row(s)
|
|
43
|
+
{ id: s.id, name: s.name, duration: s.duration_ms.round(3), status: s.status, attributes: s.payload,
|
|
44
|
+
occurred_at: s.occurred_at, execution_id: s.execution_id, execution_preview: s.execution_preview }
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Railwatch
|
|
4
|
+
class StorageOpsController < DashboardController
|
|
5
|
+
def index
|
|
6
|
+
from, to = window_range
|
|
7
|
+
ops = telemetry do
|
|
8
|
+
Telemetry::StorageOp.between(from, to).group(:service, :op).pluck(:service, :op, Arel.sql("COUNT(*)"), Arel.sql("AVG(duration)"), Arel.sql("MAX(duration)"))
|
|
9
|
+
.map { |s, o, c, avg, mx| { service: s, op: o, count: c, avg: (avg.to_f / 1000.0).round(1), max: (mx.to_f / 1000.0).round(1) } }
|
|
10
|
+
end
|
|
11
|
+
recent = telemetry { Telemetry::StorageOp.between(from, to).recent.limit(100).map { |r| { id: r.id, service: r.service, op: r.op, key: r.key, duration: r.duration_ms.round(1), occurred_at: r.occurred_at, execution_id: r.execution_id, execution_preview: r.execution_preview } } }
|
|
12
|
+
render inertia: { ops: ops, recent: recent }
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Railwatch
|
|
4
|
+
class TenantsController < DashboardController
|
|
5
|
+
def index
|
|
6
|
+
from, to = window_range
|
|
7
|
+
tenants, summary = telemetry do
|
|
8
|
+
rows = Telemetry::Tenant.index(from, to, q: params[:q], sort: params[:sort], dir: params[:dir])
|
|
9
|
+
[ rows, Telemetry::Tenant.overview(rows, from, to) ]
|
|
10
|
+
end
|
|
11
|
+
render inertia: { tenants: tenants, summary: summary,
|
|
12
|
+
sort: params[:sort] || "requests", dir: params[:dir] || "desc", q: params[:q].to_s }
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# :id is the tenant string. Rails reads a dot in a path segment as a format
|
|
16
|
+
# separator, so tenants like "acme.co" arrive percent-encoded (see the
|
|
17
|
+
# tenants index page's tenantPath).
|
|
18
|
+
def show
|
|
19
|
+
tenant = params[:id]
|
|
20
|
+
from, to = window_range
|
|
21
|
+
data = telemetry do
|
|
22
|
+
{ tenant: tenant, summary: { current: Telemetry::Tenant.summary(tenant, from, to),
|
|
23
|
+
previous: Telemetry::Tenant.summary(tenant, *previous_window_range) },
|
|
24
|
+
series: Telemetry::Tenant.series(tenant, from, to), routes: Telemetry::Tenant.routes(tenant, from, to),
|
|
25
|
+
jobs: Telemetry::Tenant.job_classes(tenant, from, to), exceptions: Telemetry::Tenant.exceptions(tenant, from, to),
|
|
26
|
+
people: Telemetry::Tenant.people(tenant), recent_requests: Telemetry::Tenant.recent_requests(tenant, from, to) }
|
|
27
|
+
end
|
|
28
|
+
issues = environment.issues.where(group_hash: data[:exceptions].map { |e| e[:group_hash] }.uniq).index_by(&:group_hash)
|
|
29
|
+
render inertia: data.merge(exceptions: data[:exceptions].map { |e| with_issue(e, issues) }, links: links(tenant))
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def with_issue(exception, issues)
|
|
35
|
+
issue = issues[exception[:group_hash]]
|
|
36
|
+
exception.merge(issue_id: issue&.id, issue_key: issue&.key)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def links(tenant)
|
|
40
|
+
{ logs: application_environment_logs_path(application, environment, window: window_key, q: "tenant:#{tenant}"),
|
|
41
|
+
people: application_environment_people_path(application, environment, window: window_key) }
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Railwatch
|
|
4
|
+
class ThresholdsController < DashboardController
|
|
5
|
+
def index
|
|
6
|
+
render inertia: { thresholds: environment.thresholds.order(:target_kind, :target).map { |t| t.slice(:id, :target_kind, :target, :metric, :limit, :window_minutes).merge(description: t.description) },
|
|
7
|
+
routes: telemetry { Telemetry::Rollup.for_type("request").where("bucket > ?", 7.days.ago).distinct.pluck(:name).sort },
|
|
8
|
+
jobs: telemetry { Telemetry::Rollup.for_type("job_attempt").where("bucket > ?", 7.days.ago).distinct.pluck(:name).sort },
|
|
9
|
+
kinds: Threshold::TARGET_KINDS, metrics: Threshold::METRICS,
|
|
10
|
+
anomaly_rules: environment.anomaly_rules.order(:target_kind, :target).map { |r| r.slice(:id, :target_kind, :target, :metric, :deviation, :window_minutes, :baseline_days, :enabled, :last_fired_at).merge(description: r.description) },
|
|
11
|
+
anomaly_target_kinds: AnomalyRule::TARGET_KINDS, anomaly_metrics: AnomalyRule::METRICS }
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def create
|
|
15
|
+
threshold = environment.thresholds.new(threshold_params)
|
|
16
|
+
if threshold.save
|
|
17
|
+
redirect_to application_environment_thresholds_path(application, environment), notice: "Threshold added"
|
|
18
|
+
else
|
|
19
|
+
redirect_to application_environment_thresholds_path(application, environment), inertia: { errors: threshold.errors }
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def update
|
|
24
|
+
threshold = environment.thresholds.find(params[:id])
|
|
25
|
+
threshold.update(threshold_params)
|
|
26
|
+
redirect_to application_environment_thresholds_path(application, environment), notice: "Threshold updated"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def destroy
|
|
30
|
+
environment.thresholds.find(params[:id]).destroy
|
|
31
|
+
redirect_to application_environment_thresholds_path(application, environment), notice: "Threshold removed"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def threshold_params
|
|
37
|
+
params.permit(:target_kind, :target, :metric, :limit, :window_minutes)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# One distributed trace: every execution that shares a trace_id, nested by
|
|
4
|
+
# the parent_id the gem propagates (the enqueuing execution's id for jobs,
|
|
5
|
+
# the W3C traceparent parent-id for HTTP hops).
|
|
6
|
+
module Railwatch
|
|
7
|
+
class TracesController < DashboardController
|
|
8
|
+
LIMIT = 200
|
|
9
|
+
|
|
10
|
+
def show
|
|
11
|
+
trace_id = params[:trace_id]
|
|
12
|
+
executions, outgoing = telemetry do
|
|
13
|
+
rows = Telemetry::Execution.where(trace_id: trace_id).order(:occurred_at).limit(LIMIT).to_a
|
|
14
|
+
[ rows, Telemetry::OutgoingRequest.where(execution_id: rows.map(&:execution_id)).order(:occurred_at).group_by(&:execution_id) ]
|
|
15
|
+
end
|
|
16
|
+
raise ActiveRecord::RecordNotFound if executions.empty?
|
|
17
|
+
|
|
18
|
+
@start = executions.first.occurred_at
|
|
19
|
+
@outgoing = outgoing
|
|
20
|
+
render inertia: { trace_id: trace_id, roots: tree(executions),
|
|
21
|
+
span_count: executions.size + outgoing.values.sum(&:size),
|
|
22
|
+
services: executions.filter_map(&:server).uniq, duration: total_duration(executions) }
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
# An execution's parent is the one whose execution_id matches its
|
|
28
|
+
# parent_id outright (a job enqueued by that execution) or whose id
|
|
29
|
+
# matches once dashes are dropped and it is cut to the 16 hex chars a
|
|
30
|
+
# W3C traceparent carries (an incoming HTTP hop). Anything whose parent
|
|
31
|
+
# is missing -- the trace's entry point, or a hop whose caller was not
|
|
32
|
+
# reported -- is a root.
|
|
33
|
+
def tree(executions)
|
|
34
|
+
by_id = executions.index_by(&:execution_id)
|
|
35
|
+
by_short_id = executions.index_by { |e| e.execution_id.to_s.delete("-").first(16) }
|
|
36
|
+
children = Hash.new { |hash, key| hash[key] = [] }
|
|
37
|
+
roots = []
|
|
38
|
+
executions.each do |exe|
|
|
39
|
+
parent = exe.parent_id.presence && (by_id[exe.parent_id] || by_short_id[exe.parent_id])
|
|
40
|
+
parent = nil if parent && parent.execution_id == exe.execution_id
|
|
41
|
+
parent ? children[parent.execution_id] << exe : roots << exe
|
|
42
|
+
end
|
|
43
|
+
roots.map { |exe| node(exe, children) }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def node(exe, children)
|
|
47
|
+
{ execution: execution_row(exe), children: children[exe.execution_id].map { |child| node(child, children) } }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def execution_row(exe)
|
|
51
|
+
{ execution_id: exe.execution_id, kind: exe.kind, name: exe.name, status: exe.status, outcome: exe.outcome,
|
|
52
|
+
parent_id: exe.parent_id, server: exe.server, occurred_at: exe.occurred_at,
|
|
53
|
+
duration: exe.duration_ms.round(2), offset: offset(exe.occurred_at),
|
|
54
|
+
outgoing: @outgoing.fetch(exe.execution_id, []).map { |o| outgoing_row(o) } }
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def outgoing_row(o)
|
|
58
|
+
{ id: o.id, method: o.method, url: o.url.to_s.first(200), status_code: o.status_code,
|
|
59
|
+
duration: o.duration_ms&.round(2), offset: offset(o.occurred_at) }
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def offset(time)
|
|
63
|
+
((time - @start) * 1000.0).round(2)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Wall-clock span of the whole trace: the last thing to finish, minus the
|
|
67
|
+
# first thing to start.
|
|
68
|
+
def total_duration(executions)
|
|
69
|
+
executions.map { |e| offset(e.occurred_at) + e.duration_ms }.max.round(2)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Railwatch
|
|
4
|
+
class TransactionsController < DashboardController
|
|
5
|
+
def index
|
|
6
|
+
from, to = window_range
|
|
7
|
+
rows = grouped("transaction", limit: 200, order: params[:sort], dir: params[:dir])
|
|
8
|
+
recent = telemetry { Telemetry::Transaction.between(from, to).recent.limit(50).map { |t| transaction_row(t) } }
|
|
9
|
+
render inertia: { transactions: rows, series: series("transaction"), recent: recent,
|
|
10
|
+
sort: params[:sort] || "count", dir: params[:dir] || "desc" }
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
private
|
|
14
|
+
|
|
15
|
+
def transaction_row(t)
|
|
16
|
+
{ id: t.id, outcome: t.outcome, connection: t.connection, duration: t.duration_ms.round(3), statement_count: t.statement_count, occurred_at: t.occurred_at,
|
|
17
|
+
execution_id: t.execution_id, execution_source: t.execution_source, execution_preview: t.execution_preview,
|
|
18
|
+
group_hash: t.group_hash }
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Railwatch
|
|
4
|
+
class ViewRendersController < DashboardController
|
|
5
|
+
def index
|
|
6
|
+
from, to = window_range
|
|
7
|
+
fields = FilterQuery.parse(params[:q]).fetch(:fields)
|
|
8
|
+
rows = grouped("view_render", limit: 200, order: params[:sort], dir: params[:dir])
|
|
9
|
+
rows = rows.select { |r| r[:name].include?(fields["template"]) } if fields["template"].present?
|
|
10
|
+
kinds = telemetry do
|
|
11
|
+
Telemetry::Rollup.for_type("view_render").between(from, to).to_a.group_by(&:group_hash)
|
|
12
|
+
.transform_values { |group_rows| group_rows.max_by(&:bucket).extra["kind"] }
|
|
13
|
+
end
|
|
14
|
+
rows.each { |r| r[:kind] = kinds[r[:group_hash]] }
|
|
15
|
+
slowest = telemetry { Telemetry::ViewRender.between(from, to).order(duration: :desc).limit(30).map { |v| render_row(v) } }
|
|
16
|
+
render inertia: { renders: rows, series: series("view_render"), slowest: slowest,
|
|
17
|
+
sort: params[:sort] || "count", dir: params[:dir] || "desc", q: params[:q].to_s }
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
def render_row(v)
|
|
23
|
+
{ id: v.id, identifier: v.identifier, kind: v.kind, layout: v.layout, duration: v.duration_ms.round(3),
|
|
24
|
+
occurred_at: v.occurred_at, execution_id: v.execution_id, execution_source: v.execution_source,
|
|
25
|
+
execution_preview: v.execution_preview, group_hash: v.group_hash }
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Railwatch
|
|
4
|
+
class VisitsController < DashboardController
|
|
5
|
+
# Vitals come from raw visit rows (rollups only carry the visit duration),
|
|
6
|
+
# so cap how many we read for a p75.
|
|
7
|
+
MAX_VITAL_SAMPLES = 50_000
|
|
8
|
+
|
|
9
|
+
def index
|
|
10
|
+
fields = FilterQuery.parse(params[:q]).fetch(:fields)
|
|
11
|
+
rows, vitals, component_vitals = telemetry do
|
|
12
|
+
scope = Telemetry::Visit.between(*window_range)
|
|
13
|
+
recent = fields["component"].present? ? scope.where(component: fields["component"]) : scope
|
|
14
|
+
[ recent.recent.limit(200).to_a, web_vitals(scope), component_web_vitals(scope) ]
|
|
15
|
+
end
|
|
16
|
+
components = grouped("visit", limit: 100, order: params[:sort], dir: params[:dir])
|
|
17
|
+
.map { |row| row.merge(component_vitals.transform_values { |p75s| p75s[row[:name]] }) }
|
|
18
|
+
render inertia: { components: components, series: series("visit"), vitals: vitals,
|
|
19
|
+
sort: params[:sort] || "count", dir: params[:dir] || "desc", q: params[:q].to_s,
|
|
20
|
+
recent: rows.map { |v| { id: v.id, component: v.component, url: v.url, method: v.method, duration: v.duration_ms.round(1), status: v.status, partial: v.partial, only: v.only, props_bytes: v.props_bytes, occurred_at: v.occurred_at, user_ref: v.user_ref, lcp: v.lcp, cls: v.cls, inp: v.inp, ttfb: v.ttfb } } }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
# p75 of each web vital in the window, with its rating and sample count.
|
|
26
|
+
# Metrics nobody reported are left out entirely.
|
|
27
|
+
def web_vitals(scope)
|
|
28
|
+
Telemetry::Visit::VITALS.keys.filter_map { |metric|
|
|
29
|
+
values = samples(scope, metric).pluck(metric)
|
|
30
|
+
next if values.empty?
|
|
31
|
+
value = p75(values)
|
|
32
|
+
[ metric, { value: value, rating: Telemetry::Visit.rating(metric, value), samples: values.size } ]
|
|
33
|
+
}.to_h
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# { lcp: { "widgets/index" => 2100 }, inp: { ... } } for the components table.
|
|
37
|
+
def component_web_vitals(scope)
|
|
38
|
+
%i[lcp inp].index_with do |metric|
|
|
39
|
+
samples(scope, metric).pluck(:component, metric).group_by(&:first)
|
|
40
|
+
.transform_values { |pairs| p75(pairs.map(&:last)) }
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def samples(scope, metric)
|
|
45
|
+
scope.where.not(metric => nil).recent.limit(MAX_VITAL_SAMPLES)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def p75(values)
|
|
49
|
+
sorted = values.sort
|
|
50
|
+
sorted[[ (sorted.size * 0.75).ceil - 1, 0 ].max].round(3)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Railwatch
|
|
4
|
+
# Emits the dashboard's script and stylesheet tags from the manifest Vite
|
|
5
|
+
# wrote at gem build time. The gem ships the built bundle under
|
|
6
|
+
# public/railwatch, so a host app needs no Node toolchain and no asset
|
|
7
|
+
# pipeline integration: the engine serves those files itself and this
|
|
8
|
+
# helper does the little that rails_vite's vite_tags would have done.
|
|
9
|
+
module AssetsHelper
|
|
10
|
+
MANIFEST = Railwatch::Engine.root.join("public/railwatch/manifest.json")
|
|
11
|
+
ASSET_PREFIX = "/railwatch/assets"
|
|
12
|
+
|
|
13
|
+
def railwatch_asset_tags(nonce: nil)
|
|
14
|
+
manifest = railwatch_manifest
|
|
15
|
+
tags = []
|
|
16
|
+
preloaded = Set.new
|
|
17
|
+
%w[app/frontend/entrypoints/application.css app/frontend/entrypoints/inertia.tsx].each do |entry|
|
|
18
|
+
info = manifest.fetch(entry)
|
|
19
|
+
Array(info["imports"]).each do |key|
|
|
20
|
+
file = manifest.fetch(key)["file"]
|
|
21
|
+
next unless preloaded.add?(file)
|
|
22
|
+
tags << tag.link(rel: "modulepreload", href: "#{ASSET_PREFIX}/#{file}", nonce: nonce)
|
|
23
|
+
end
|
|
24
|
+
tags << if info["file"].end_with?(".css")
|
|
25
|
+
tag.link(rel: "stylesheet", href: "#{ASSET_PREFIX}/#{info['file']}", nonce: nonce)
|
|
26
|
+
else
|
|
27
|
+
tag.script(src: "#{ASSET_PREFIX}/#{info['file']}", type: "module", nonce: nonce)
|
|
28
|
+
end
|
|
29
|
+
Array(info["css"]).each { |css| tags << tag.link(rel: "stylesheet", href: "#{ASSET_PREFIX}/#{css}", nonce: nonce) }
|
|
30
|
+
end
|
|
31
|
+
safe_join(tags, "\n")
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def railwatch_font_preloads
|
|
35
|
+
safe_join(railwatch_manifest.select { |k, _| k.end_with?(".woff2") && k.include?("Roboto") }.map { |_, v|
|
|
36
|
+
tag.link(rel: "preload", href: "#{ASSET_PREFIX}/#{v['file']}", as: "font", type: "font/woff2", crossorigin: "anonymous")
|
|
37
|
+
}, "\n")
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# The bundle digest doubles as the Inertia asset version: a gem upgrade
|
|
41
|
+
# changes it and every open tab does a full reload on its next visit.
|
|
42
|
+
def self.digest
|
|
43
|
+
@digest ||= Digest::SHA256.file(MANIFEST).hexdigest[0, 16]
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def railwatch_manifest
|
|
49
|
+
@railwatch_manifest ||= JSON.parse(File.read(MANIFEST))
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Railwatch
|
|
4
|
+
# Auto-resolves open issues that have gone quiet for a configured number of
|
|
5
|
+
# days (nil = off). Resolved with no deploy attached, so any recurrence is
|
|
6
|
+
# treated as a regression rather than a duplicate. Runs daily (recurring.yml).
|
|
7
|
+
class AutoResolveIssuesJob < ApplicationJob
|
|
8
|
+
queue_as :default
|
|
9
|
+
|
|
10
|
+
def perform
|
|
11
|
+
days = Railwatch::Embedded::Account.auto_resolve_after_days
|
|
12
|
+
return unless days
|
|
13
|
+
|
|
14
|
+
Issue.open.where("last_seen_at < ?", days.days.ago).find_each do |issue|
|
|
15
|
+
issue.resolve!(deploy: nil)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Railwatch
|
|
4
|
+
# Flags scheduled tasks that have not run when expected. Uses the schedule
|
|
5
|
+
# string the gem sends (Solid Queue's cron / natural language) parsed with
|
|
6
|
+
# fugit, which Solid Queue already depends on.
|
|
7
|
+
class CheckScheduledTasksJob < ApplicationJob
|
|
8
|
+
include DetectionSnapshotting
|
|
9
|
+
|
|
10
|
+
queue_as :default
|
|
11
|
+
|
|
12
|
+
GRACE = 5.minutes
|
|
13
|
+
# A deploy restarts the worker, and Solid Queue skips (does not replay) the
|
|
14
|
+
# recurring ticks that fell inside the restart window. A tick missed within
|
|
15
|
+
# this long after a deploy is the deploy, not the scheduler.
|
|
16
|
+
DEPLOY_GRACE = 15.minutes
|
|
17
|
+
|
|
18
|
+
def perform(environment)
|
|
19
|
+
require "fugit"
|
|
20
|
+
tasks = environment.with_telemetry do
|
|
21
|
+
last_runs = Telemetry::Execution.scheduled.where("occurred_at > ?", 30.days.ago).group(:task_key).maximum(:occurred_at)
|
|
22
|
+
schedules = Telemetry::Execution.latest_schedules(last_runs.keys)
|
|
23
|
+
last_runs.map { |key, last| [ key, last, schedules[key] ] }
|
|
24
|
+
end
|
|
25
|
+
last_deploy_at = environment.deploys.maximum(:deployed_at)
|
|
26
|
+
configured = environment.with_telemetry { Telemetry::HealthSample.recurring_task_keys }
|
|
27
|
+
open_missed = environment.issues.open.where("group_hash LIKE 'missed:%'").index_by(&:group_hash)
|
|
28
|
+
tasks.each do |key, last_run, schedule|
|
|
29
|
+
next if schedule.blank?
|
|
30
|
+
# A task taken out of config/recurring.yml stops running on purpose.
|
|
31
|
+
# Its history would otherwise read as a miss every window for 30 days
|
|
32
|
+
# (reconcile_ingest_batches was live for four minutes and flagged 62
|
|
33
|
+
# times). The gem ships the schedule Solid Queue is actually running
|
|
34
|
+
# on every health sample; a key absent from the newest manifest is
|
|
35
|
+
# removed, not missed. No manifest yet means an older gem: judge from
|
|
36
|
+
# history alone, as before.
|
|
37
|
+
if configured && !configured.include?(key)
|
|
38
|
+
resolve_missed!(open_missed["missed:#{key}"], "Auto-resolved: #{key} is no longer a configured recurring task.")
|
|
39
|
+
next
|
|
40
|
+
end
|
|
41
|
+
cron = Fugit.parse(schedule) or next
|
|
42
|
+
expected = cron.next_time(last_run).to_t
|
|
43
|
+
if expected > Time.current - GRACE
|
|
44
|
+
# The task is back on schedule; a missed-run issue for it heals on its own.
|
|
45
|
+
resolve_missed!(open_missed["missed:#{key}"], "Auto-resolved: #{key} ran again on schedule.")
|
|
46
|
+
next
|
|
47
|
+
end
|
|
48
|
+
next if last_deploy_at && expected.between?(last_deploy_at - DEPLOY_GRACE, last_deploy_at + DEPLOY_GRACE)
|
|
49
|
+
diagnosis = diagnose(key, expected)
|
|
50
|
+
issue, outcome = Issue.record_occurrence!(
|
|
51
|
+
environment: environment, group_hash: "missed:#{key}", kind: "performance",
|
|
52
|
+
title: "Scheduled task #{key} missed its run (expected #{expected.utc.iso8601})#{diagnosis[:title_suffix]}",
|
|
53
|
+
culprit: key, occurred_at: Time.current, deploy: nil, user_ref: nil,
|
|
54
|
+
sample: { task_key: key, schedule: schedule, last_run: last_run.iso8601, expected: expected.iso8601 }.merge(diagnosis[:sample]))
|
|
55
|
+
next unless outcome == :new || outcome == :regressed
|
|
56
|
+
|
|
57
|
+
# Only on a new or regressed issue: the detectors re-run every few minutes
|
|
58
|
+
# and capturing a snapshot on every breached window would query telemetry
|
|
59
|
+
# for an issue nobody is newly looking at.
|
|
60
|
+
latest = environment.with_telemetry { Telemetry::Execution.scheduled.where(task_key: key).recent.first }
|
|
61
|
+
issue.update!(sample: issue.sample.merge("detection" => IssueDetectionSnapshot.capture(
|
|
62
|
+
environment: environment, kind: "performance", telemetry_type: "scheduled_task",
|
|
63
|
+
telemetry_group_hash: latest.group_hash, target: key, metric: "missed", from: expected, to: Time.current,
|
|
64
|
+
representative_from: last_run,
|
|
65
|
+
rule: { type: "schedule", task_key: key, schedule: schedule,
|
|
66
|
+
last_run: last_run.iso8601, expected: expected.iso8601 }
|
|
67
|
+
)))
|
|
68
|
+
payload = { issue_key: issue.key, title: issue.title, environment: environment.name }
|
|
69
|
+
environment.application.alert_rules.where(event: "threshold").find_each do |rule|
|
|
70
|
+
next unless rule.matches?(issue: issue, payload: payload)
|
|
71
|
+
rule.fire!(event: "threshold", issue: issue, payload: payload)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
private
|
|
77
|
+
|
|
78
|
+
# Where the run went, from Solid Queue's own ledger when it is in this
|
|
79
|
+
# process. A RecurringExecution row is written by the scheduler the moment
|
|
80
|
+
# it enqueues a run, so its presence without a scheduled_task execution
|
|
81
|
+
# means "enqueued, never performed" (no worker, or a wedged one); its
|
|
82
|
+
# absence means the scheduler itself never fired. The hosted platform, and
|
|
83
|
+
# any adapter other than Solid Queue, only ever sees the absence of the
|
|
84
|
+
# run and reports it as before.
|
|
85
|
+
def diagnose(key, expected)
|
|
86
|
+
return NO_DIAGNOSIS unless defined?(::SolidQueue::RecurringExecution)
|
|
87
|
+
|
|
88
|
+
enqueued_at = Railwatch.ignore { ::SolidQueue::RecurringExecution.where(task_key: key).where("run_at >= ?", expected - 1.minute).maximum(:run_at) }
|
|
89
|
+
workers = Railwatch.ignore { ::SolidQueue::Process.where(kind: "Worker").count }
|
|
90
|
+
if enqueued_at
|
|
91
|
+
{ title_suffix: workers.zero? ? ": enqueued, but no Solid Queue worker is running" : ": enqueued, not yet performed",
|
|
92
|
+
sample: { enqueued_at: enqueued_at.iso8601, workers: workers, cause: workers.zero? ? "no_worker" : "backlog" } }
|
|
93
|
+
else
|
|
94
|
+
{ title_suffix: ": the scheduler never enqueued it",
|
|
95
|
+
sample: { enqueued_at: nil, workers: workers, cause: "scheduler" } }
|
|
96
|
+
end
|
|
97
|
+
rescue StandardError
|
|
98
|
+
NO_DIAGNOSIS
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
NO_DIAGNOSIS = { title_suffix: "", sample: {} }.freeze
|
|
102
|
+
|
|
103
|
+
# Sentry Crons marked a monitor OK again on the next successful check-in;
|
|
104
|
+
# the same here: once the task has run inside its window again, the open
|
|
105
|
+
# missed-run issue resolves with an activity row, so it does not sit open
|
|
106
|
+
# until someone notices it is stale.
|
|
107
|
+
def resolve_missed!(issue, body)
|
|
108
|
+
return unless issue
|
|
109
|
+
issue.resolve!
|
|
110
|
+
issue.activities.create!(kind: "comment", data: { body: body })
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|