railwatch 0.1.4 → 0.2.0.pre1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +205 -0
- data/README.md +10 -0
- data/app/channels/railwatch/environment_channel.rb +28 -0
- data/app/controllers/concerns/railwatch/telemetry_identity.rb +25 -0
- data/app/controllers/railwatch/alerts_controller.rb +48 -0
- data/app/controllers/railwatch/anomaly_rules_controller.rb +31 -0
- data/app/controllers/railwatch/attachments_controller.rb +22 -0
- data/app/controllers/railwatch/beacon_controller.rb +67 -7
- data/app/controllers/railwatch/broadcasts_controller.rb +15 -0
- data/app/controllers/railwatch/cache_events_controller.rb +30 -0
- data/app/controllers/railwatch/commands_controller.rb +16 -0
- data/app/controllers/railwatch/comments_controller.rb +11 -0
- data/app/controllers/railwatch/dashboard_controller.rb +69 -0
- data/app/controllers/railwatch/deploys_controller.rb +34 -0
- data/app/controllers/railwatch/deprecations_controller.rb +54 -0
- data/app/controllers/railwatch/environment_scoped.rb +99 -0
- data/app/controllers/railwatch/exceptions_controller.rb +53 -0
- data/app/controllers/railwatch/executions_controller.rb +13 -0
- data/app/controllers/railwatch/issues_controller.rb +258 -0
- data/app/controllers/railwatch/jobs_controller.rb +63 -0
- data/app/controllers/railwatch/llm_calls_controller.rb +124 -0
- data/app/controllers/railwatch/logs_controller.rb +40 -0
- data/app/controllers/railwatch/mails_controller.rb +19 -0
- data/app/controllers/railwatch/notifications_controller.rb +11 -0
- data/app/controllers/railwatch/outgoing_requests_controller.rb +19 -0
- data/app/controllers/railwatch/overview_controller.rb +38 -0
- data/app/controllers/railwatch/people_controller.rb +25 -0
- data/app/controllers/railwatch/processes_controller.rb +33 -0
- data/app/controllers/railwatch/profiles_controller.rb +50 -0
- data/app/controllers/railwatch/queries_controller.rb +59 -0
- data/app/controllers/railwatch/releases_controller.rb +75 -0
- data/app/controllers/railwatch/requests_controller.rb +55 -0
- data/app/controllers/railwatch/saved_views_controller.rb +53 -0
- data/app/controllers/railwatch/scheduled_tasks_controller.rb +45 -0
- data/app/controllers/railwatch/spans_controller.rb +47 -0
- data/app/controllers/railwatch/storage_ops_controller.rb +15 -0
- data/app/controllers/railwatch/tenants_controller.rb +44 -0
- data/app/controllers/railwatch/thresholds_controller.rb +40 -0
- data/app/controllers/railwatch/traces_controller.rb +72 -0
- data/app/controllers/railwatch/transactions_controller.rb +21 -0
- data/app/controllers/railwatch/view_renders_controller.rb +28 -0
- data/app/controllers/railwatch/visits_controller.rb +53 -0
- data/app/helpers/railwatch/assets_helper.rb +52 -0
- data/app/jobs/railwatch/anomaly_scan_job.rb +11 -0
- data/app/jobs/railwatch/application_job.rb +7 -0
- data/app/jobs/railwatch/auto_resolve_issues_job.rb +19 -0
- data/app/jobs/railwatch/check_scheduled_tasks_job.rb +113 -0
- data/app/jobs/railwatch/detect_anomalies_job.rb +171 -0
- data/app/jobs/railwatch/detect_performance_issues_job.rb +85 -0
- data/app/jobs/railwatch/group_exceptions_job.rb +91 -0
- data/app/jobs/railwatch/optimize_telemetry_job.rb +23 -0
- data/app/jobs/railwatch/performance_scan_job.rb +11 -0
- data/app/jobs/railwatch/prune_telemetry_job.rb +110 -0
- data/app/jobs/railwatch/release_health_rollup_job.rb +64 -0
- data/app/jobs/railwatch/rollup_catchup_job.rb +21 -0
- data/app/jobs/railwatch/rollup_job.rb +130 -0
- data/app/jobs/railwatch/scheduled_task_scan_job.rb +11 -0
- data/app/models/concerns/railwatch/detection_snapshotting.rb +20 -0
- data/app/models/railwatch/alert.rb +229 -0
- data/app/models/railwatch/alert_rule.rb +121 -0
- data/app/models/railwatch/anomaly_rule.rb +33 -0
- data/app/models/railwatch/application.rb +44 -0
- data/app/models/railwatch/application_record.rb +33 -0
- data/app/models/railwatch/comment.rb +36 -0
- data/app/models/railwatch/deploy.rb +67 -0
- data/app/models/railwatch/environment.rb +54 -0
- data/app/models/railwatch/execution_presenter.rb +185 -0
- data/app/models/railwatch/filter_query.rb +143 -0
- data/app/models/railwatch/followup_receipt.rb +27 -0
- data/app/models/railwatch/ingest/batch.rb +305 -0
- data/app/models/railwatch/ingest/mapper.rb +575 -0
- data/app/models/railwatch/ingest/payload.rb +96 -0
- data/app/models/railwatch/ingest/rollup_absorber.rb +170 -0
- data/app/models/railwatch/ingest/writer.rb +137 -0
- data/app/models/railwatch/issue.rb +277 -0
- data/app/models/railwatch/issue_activity.rb +23 -0
- data/app/models/railwatch/issue_detection_presenter.rb +309 -0
- data/app/models/railwatch/issue_detection_snapshot.rb +77 -0
- data/app/models/railwatch/maintenance_task.rb +53 -0
- data/app/models/railwatch/saved_view.rb +63 -0
- data/app/models/railwatch/telemetry/aggregations.rb +116 -0
- data/app/models/railwatch/telemetry/attachment.rb +31 -0
- data/app/models/railwatch/telemetry/bounded_gzip.rb +101 -0
- data/app/models/railwatch/telemetry/broadcast.rb +13 -0
- data/app/models/railwatch/telemetry/cache_event.rb +16 -0
- data/app/models/railwatch/telemetry/child.rb +53 -0
- data/app/models/railwatch/telemetry/cursor_page.rb +99 -0
- data/app/models/railwatch/telemetry/deprecation.rb +13 -0
- data/app/models/railwatch/telemetry/enqueued_job.rb +13 -0
- data/app/models/railwatch/telemetry/exception.rb +21 -0
- data/app/models/railwatch/telemetry/execution.rb +71 -0
- data/app/models/railwatch/telemetry/health_sample.rb +72 -0
- data/app/models/railwatch/telemetry/ingest_batch.rb +31 -0
- data/app/models/railwatch/telemetry/llm_call.rb +37 -0
- data/app/models/railwatch/telemetry/log.rb +85 -0
- data/app/models/railwatch/telemetry/mail.rb +13 -0
- data/app/models/railwatch/telemetry/n_plus_one.rb +92 -0
- data/app/models/railwatch/telemetry/notification.rb +13 -0
- data/app/models/railwatch/telemetry/outgoing_request.rb +13 -0
- data/app/models/railwatch/telemetry/person.rb +50 -0
- data/app/models/railwatch/telemetry/process.rb +10 -0
- data/app/models/railwatch/telemetry/profile.rb +37 -0
- data/app/models/railwatch/telemetry/query.rb +50 -0
- data/app/models/railwatch/telemetry/query_shape.rb +45 -0
- data/app/models/railwatch/telemetry/release_health.rb +63 -0
- data/app/models/railwatch/telemetry/rollup.rb +78 -0
- data/app/models/railwatch/telemetry/session.rb +24 -0
- data/app/models/railwatch/telemetry/span.rb +19 -0
- data/app/models/railwatch/telemetry/storage_op.rb +13 -0
- data/app/models/railwatch/telemetry/tenant.rb +221 -0
- data/app/models/railwatch/telemetry/transaction.rb +13 -0
- data/app/models/railwatch/telemetry/view_render.rb +13 -0
- data/app/models/railwatch/telemetry/visit.rb +24 -0
- data/app/models/railwatch/telemetry_record.rb +57 -0
- data/app/models/railwatch/threshold.rb +29 -0
- data/app/models/railwatch/user.rb +47 -0
- data/app/models/railwatch/viewer.rb +13 -0
- data/app/views/layouts/railwatch/dashboard.html.erb +25 -0
- data/config/routes.rb +59 -0
- data/db/railwatch_migrate/20260916000000_create_railwatch_tables.rb +151 -0
- data/db/railwatch_migrate/20260917000000_create_railwatch_maintenance_tasks.rb +18 -0
- data/db/railwatch_migrate/20260917120000_create_railwatch_followup_receipts.rb +20 -0
- data/db/railwatch_telemetry_migrate/20260903000001_create_telemetry.rb +481 -0
- data/db/railwatch_telemetry_migrate/20260903000002_rename_tenant_to_app_tenant.rb +14 -0
- data/db/railwatch_telemetry_migrate/20260903000003_add_statement_count_to_transactions.rb +9 -0
- data/db/railwatch_telemetry_migrate/20260903000004_add_role_and_channel.rb +10 -0
- data/db/railwatch_telemetry_migrate/20260903000005_add_locals_to_exceptions.rb +9 -0
- data/db/railwatch_telemetry_migrate/20260903000006_add_spans_health_vitals_and_fts.rb +82 -0
- data/db/railwatch_telemetry_migrate/20260903000007_rename_span_attributes_to_payload.rb +10 -0
- data/db/railwatch_telemetry_migrate/20260903000008_add_profiles_and_attachments.rb +60 -0
- data/db/railwatch_telemetry_migrate/20260903000009_add_truncated_to_attachments.rb +9 -0
- data/db/railwatch_telemetry_migrate/20260903000010_create_sessions_and_release_health.rb +51 -0
- data/db/railwatch_telemetry_migrate/20260903000011_add_fingerprint_to_exceptions.rb +11 -0
- data/db/railwatch_telemetry_migrate/20260904000012_add_failed_to_broadcasts.rb +10 -0
- data/db/railwatch_telemetry_migrate/20260904010000_add_filter_cursor_indexes.rb +37 -0
- data/db/railwatch_telemetry_migrate/20260904020000_add_n_plus_ones_execution_id_index.rb +11 -0
- data/db/railwatch_telemetry_migrate/20260904120000_create_query_shapes.rb +15 -0
- data/db/railwatch_telemetry_migrate/20260906120000_add_backpressure_factor_to_ingest_batches.rb +9 -0
- data/db/railwatch_telemetry_migrate/20260907000000_rename_lantern_version_on_processes.rb +10 -0
- data/db/railwatch_telemetry_migrate/20260913000000_rename_nightrail_version_on_processes.rb +9 -0
- data/db/railwatch_telemetry_migrate/20260914000000_drop_orphan_durable_ingest_tables.rb +18 -0
- data/db/railwatch_telemetry_migrate/20260914010000_drop_orphan_durable_ingest_columns.rb +25 -0
- data/db/railwatch_telemetry_migrate/20260915000000_create_llm_calls.rb +55 -0
- data/db/railwatch_telemetry_migrate/20260915120000_add_detail_to_llm_calls.rb +21 -0
- data/db/railwatch_telemetry_migrate/20260915200000_add_explained_index_to_queries.rb +16 -0
- data/db/railwatch_telemetry_migrate/20260915210000_add_slowest_index_to_queries.rb +18 -0
- data/db/railwatch_telemetry_migrate/20260917010000_add_batch_ledger_to_ingest_batches.rb +16 -0
- data/docs/configuration.md +26 -0
- data/docs/embedded.md +342 -0
- data/docs/getting-started.md +5 -0
- data/lib/generators/railwatch/install/install_generator.rb +222 -1
- data/lib/generators/railwatch/install/templates/{initializer.rb → initializer.rb.tt} +39 -0
- data/lib/generators/railwatch/install/templates/post-deploy +8 -0
- data/lib/puma/plugin/railwatch.rb +170 -0
- data/lib/railwatch/authentication.rb +83 -0
- data/lib/railwatch/configuration.rb +134 -3
- data/lib/railwatch/dashboard_assets.rb +45 -0
- data/lib/railwatch/embedded.rb +51 -0
- data/lib/railwatch/engine.rb +89 -1
- data/lib/railwatch/ingest_request_body_limit.rb +10 -0
- data/lib/railwatch/json_compat.rb +60 -0
- data/lib/railwatch/maintenance.rb +183 -0
- data/lib/railwatch/patches/runner_command.rb +21 -1
- data/lib/railwatch/record.rb +33 -8
- data/lib/railwatch/reporter.rb +16 -0
- data/lib/railwatch/subscribers/process_info.rb +2 -1
- data/lib/railwatch/transport/local.rb +78 -0
- data/lib/railwatch/transport/socket.rb +183 -0
- data/lib/railwatch/version.rb +1 -1
- data/lib/railwatch/writer.rb +370 -0
- data/lib/railwatch.rb +38 -1
- data/lib/tasks/railwatch_tasks.rake +128 -0
- data/public/railwatch/assets/CommitMono-Bold-D6h61ieg.woff2 +0 -0
- data/public/railwatch/assets/CommitMono-Regular-zr8w7Obm.woff2 +0 -0
- data/public/railwatch/assets/Roboto-Black-auA4GeOK.woff2 +0 -0
- data/public/railwatch/assets/Roboto-Bold-CJLnO8j1.woff2 +0 -0
- data/public/railwatch/assets/Roboto-Medium-Cm2bwKpj.woff2 +0 -0
- data/public/railwatch/assets/Roboto-Regular-Chaq1-PV.woff2 +0 -0
- data/public/railwatch/assets/app-layout-yh-sPWgK.js +1 -0
- data/public/railwatch/assets/app-wordmark-nbjkzxwQ.js +1 -0
- data/public/railwatch/assets/appearance-CDuRvQTB.js +1 -0
- data/public/railwatch/assets/application-C_kpBdnf.css +1 -0
- data/public/railwatch/assets/arrow-up-DVtOGdVA.js +1 -0
- data/public/railwatch/assets/auth-layout-TCwPpS1K.js +1 -0
- data/public/railwatch/assets/badge-Daw4Hvr8.js +1 -0
- data/public/railwatch/assets/braces-DhFbHPsz.js +1 -0
- data/public/railwatch/assets/card-BX_3HXcJ.js +1 -0
- data/public/railwatch/assets/chart-B14-N9g7.js +39 -0
- data/public/railwatch/assets/chart-hover-Vy52H4uD.js +1 -0
- data/public/railwatch/assets/chart-panel-Cb4S_ej_.js +1 -0
- data/public/railwatch/assets/checkbox-D3aRSsBj.js +1 -0
- data/public/railwatch/assets/code-KvW8k7Jr.js +1 -0
- data/public/railwatch/assets/copy-DaQMJWoT.js +1 -0
- data/public/railwatch/assets/copy-block-BKFGd11J.js +1 -0
- data/public/railwatch/assets/copy-id-Djks1fXB.js +1 -0
- data/public/railwatch/assets/cursor-load-more-BXV0f1D_.js +1 -0
- data/public/railwatch/assets/data-table-iv1bdF6u.js +1 -0
- data/public/railwatch/assets/edit-BZc_Iawe.js +1 -0
- data/public/railwatch/assets/edit-DMKUF8Zi.js +8 -0
- data/public/railwatch/assets/edit-__9yJlO3.js +1 -0
- data/public/railwatch/assets/empty-state-6j_0AaQQ.js +1 -0
- data/public/railwatch/assets/env-layout-DrT8rO6P.js +1 -0
- data/public/railwatch/assets/execution-path-CzgBUi5e.js +1 -0
- data/public/railwatch/assets/filter-bar-9SU5NrzX.js +1 -0
- data/public/railwatch/assets/flamegraph-qcekju8V.js +2 -0
- data/public/railwatch/assets/format-B9SDkrWj.js +1 -0
- data/public/railwatch/assets/frames-Cyu7KMxZ.js +1 -0
- data/public/railwatch/assets/google-sign-in-button-DsTSfmzY.js +1 -0
- data/public/railwatch/assets/index-1ol1-QWI.js +1 -0
- data/public/railwatch/assets/index-5jI4aFzC.js +1 -0
- data/public/railwatch/assets/index-9KTrVnrc.js +1 -0
- data/public/railwatch/assets/index-B0-8lcTp.js +1 -0
- data/public/railwatch/assets/index-B7jjfNfO.js +1 -0
- data/public/railwatch/assets/index-BBchRy0M.js +1 -0
- data/public/railwatch/assets/index-BRiq3SNR.js +1 -0
- data/public/railwatch/assets/index-BgKj9xhr.js +1 -0
- data/public/railwatch/assets/index-BkTZqqOu.js +1 -0
- data/public/railwatch/assets/index-BprKx8QO.js +1 -0
- data/public/railwatch/assets/index-C3jzvPs3.js +1 -0
- data/public/railwatch/assets/index-Cbs6gGyQ.js +1 -0
- data/public/railwatch/assets/index-CdRZ6AWF.js +1 -0
- data/public/railwatch/assets/index-CeYKnapu.js +1 -0
- data/public/railwatch/assets/index-Cmlwy1-V.js +1 -0
- data/public/railwatch/assets/index-Cwx6058d.js +1 -0
- data/public/railwatch/assets/index-D4CSdbHv.js +1 -0
- data/public/railwatch/assets/index-DEFMSkdG.js +1 -0
- data/public/railwatch/assets/index-DFiHEBSh.js +1 -0
- data/public/railwatch/assets/index-DL4vWdWJ.js +1 -0
- data/public/railwatch/assets/index-DU9F5b5d.js +1 -0
- data/public/railwatch/assets/index-DaXgPcGL.js +1 -0
- data/public/railwatch/assets/index-DbtaU-EE.js +1 -0
- data/public/railwatch/assets/index-DeOe83F4.js +1 -0
- data/public/railwatch/assets/index-DiucHN4B.js +1 -0
- data/public/railwatch/assets/index-DlnR_l9o.js +1 -0
- data/public/railwatch/assets/index-DlumCsWY.js +2 -0
- data/public/railwatch/assets/index-DmRd7aIG.js +1 -0
- data/public/railwatch/assets/index-DxSh2UpM.js +1 -0
- data/public/railwatch/assets/index-MIMGuFNt.js +1 -0
- data/public/railwatch/assets/index-OqI59zPb.js +1 -0
- data/public/railwatch/assets/index-P4rC7IlX.js +1 -0
- data/public/railwatch/assets/index-gpPOcFWq.js +1 -0
- data/public/railwatch/assets/index-oVkururr.js +1 -0
- data/public/railwatch/assets/index-p9puqVge.js +1 -0
- data/public/railwatch/assets/inertia-TViv6kNv.js +97 -0
- data/public/railwatch/assets/input-error-LxImUkxv.js +1 -0
- data/public/railwatch/assets/json-viewer-Ar4cjPDW.js +1 -0
- data/public/railwatch/assets/klass-CJ-J4INB.js +1 -0
- data/public/railwatch/assets/label-COUKWqE_.js +1 -0
- data/public/railwatch/assets/layout-DNSLAkw_.js +1 -0
- data/public/railwatch/assets/live-dot-ChfUtY3p.js +41 -0
- data/public/railwatch/assets/nav-CNnDqPlm.js +1 -0
- data/public/railwatch/assets/new-84S8ZJq9.js +1 -0
- data/public/railwatch/assets/new-Be55nmt9.js +1 -0
- data/public/railwatch/assets/new-Bi_xQiIb.js +1 -0
- data/public/railwatch/assets/new-BvCT8TMg.js +1 -0
- data/public/railwatch/assets/new-D05SajFR.js +1 -0
- data/public/railwatch/assets/new-D4uewYC8.js +1 -0
- data/public/railwatch/assets/onboarding-CYZi5Cqc.js +1 -0
- data/public/railwatch/assets/origin-identity-6q1-CBts.js +1 -0
- data/public/railwatch/assets/percentile-picker-gFZCXtdb.js +1 -0
- data/public/railwatch/assets/relative-time-IOOgl5n2.js +1 -0
- data/public/railwatch/assets/release-health-DC8oc7uw.js +1 -0
- data/public/railwatch/assets/route-Dv6LAWvT.js +1 -0
- data/public/railwatch/assets/segmented-h1VdDTqE.js +1 -0
- data/public/railwatch/assets/select-_AJsUa7X.js +1 -0
- data/public/railwatch/assets/separator-BwwTYtCF.js +1 -0
- data/public/railwatch/assets/series-chart-DaFPefku.js +1 -0
- data/public/railwatch/assets/show-B7NCgkEo.js +1 -0
- data/public/railwatch/assets/show-BKqyKjBK.js +1 -0
- data/public/railwatch/assets/show-BM6X2Mpo.js +1 -0
- data/public/railwatch/assets/show-BNw4tN5q.js +1 -0
- data/public/railwatch/assets/show-BO3bnG5h.js +1 -0
- data/public/railwatch/assets/show-BhrAVAEA.js +1 -0
- data/public/railwatch/assets/show-C4Ltf5i9.js +2 -0
- data/public/railwatch/assets/show-C8sHalnw.js +1 -0
- data/public/railwatch/assets/show-CeTL4B37.js +2 -0
- data/public/railwatch/assets/show-CpfgV1jP.js +1 -0
- data/public/railwatch/assets/show-DACku6AD.js +3 -0
- data/public/railwatch/assets/show-DIOSGcXV.js +6 -0
- data/public/railwatch/assets/show-DQp_1n-B.js +1 -0
- data/public/railwatch/assets/show-DVNz46RI.js +1 -0
- data/public/railwatch/assets/show-DYteoYWW.js +1 -0
- data/public/railwatch/assets/show-DgSIoRvA.js +1 -0
- data/public/railwatch/assets/show-JxFtB4eK.js +2 -0
- data/public/railwatch/assets/sort-header-DpFzXblu.js +1 -0
- data/public/railwatch/assets/source-link-B2183i2-.js +1 -0
- data/public/railwatch/assets/sparkline-cell-C3-5vFkP.js +1 -0
- data/public/railwatch/assets/stat-s4RpOS9w.js +1 -0
- data/public/railwatch/assets/status-badge-8jVV-LA4.js +1 -0
- data/public/railwatch/assets/tenant-path-G-6u9A-o.js +1 -0
- data/public/railwatch/assets/text-link-DfsiaCcP.js +1 -0
- data/public/railwatch/assets/textarea-Dye72uP7.js +1 -0
- data/public/railwatch/assets/timeline-CD7WHnbo.js +1 -0
- data/public/railwatch/assets/transition-B_AW8rMK.js +5 -0
- data/public/railwatch/assets/use-clipboard-ColgLyQ2.js +1 -0
- data/public/railwatch/icon.png +0 -0
- data/public/railwatch/icon.svg +5 -0
- data/public/railwatch/manifest.json +2171 -0
- data/public/railwatch/rails-vite.json +1 -0
- metadata +313 -4
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Railwatch
|
|
4
|
+
class DeploysController < DashboardController
|
|
5
|
+
def index
|
|
6
|
+
render inertia: { deploys: environment.deploys.recent.limit(100).map { |d| deploy_row(d) } }
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
# Before/after comparison: the hour before the deploy versus the hour after.
|
|
10
|
+
def show
|
|
11
|
+
deploy = environment.deploys.find(params[:id])
|
|
12
|
+
before = [ deploy.deployed_at - 1.hour, deploy.deployed_at ]
|
|
13
|
+
after = [ deploy.deployed_at, deploy.deployed_at + 1.hour ]
|
|
14
|
+
compare, health = telemetry do
|
|
15
|
+
[ %w[request job_attempt].to_h do |type|
|
|
16
|
+
[ type, { before: Telemetry::Rollup.summarize(Telemetry::Rollup.for_type(type).where(bucket: before.first.beginning_of_hour..before.last)),
|
|
17
|
+
after: Telemetry::Rollup.summarize(Telemetry::Rollup.for_type(type).where(bucket: after.first.beginning_of_hour..after.last)) } ]
|
|
18
|
+
end, Telemetry::ReleaseHealth.for_deploy(deploy.deploy, *deploy.window) ]
|
|
19
|
+
end
|
|
20
|
+
new_issues = environment.issues.where(first_seen_at: after.first..after.last + 23.hours).recent.map { |i| { id: i.id, key: i.key, title: i.title, status: i.status } }
|
|
21
|
+
resolved = environment.issues.where(resolved_in_deploy: deploy.deploy).map { |i| { id: i.id, key: i.key, title: i.title } }
|
|
22
|
+
render inertia: { deploy: deploy_row(deploy).merge(commits: deploy.commits, previous_ref: deploy.previous_ref,
|
|
23
|
+
repository_ref: deploy.ref, detail: deploy.detail),
|
|
24
|
+
compare: compare, health: health, new_issues: new_issues, resolved_issues: resolved }
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def deploy_row(d)
|
|
30
|
+
{ id: d.id, deploy: d.deploy, ref: d.short_ref, name: d.name, url: d.url, server: d.server, deployed_at: d.deployed_at,
|
|
31
|
+
commits_count: d.commits_count, performer: d.detail["performer"] }
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Railwatch
|
|
4
|
+
class DeprecationsController < DashboardController
|
|
5
|
+
def index
|
|
6
|
+
from, to = window_range
|
|
7
|
+
parsed = FilterQuery.parse(params[:q])
|
|
8
|
+
fields = parsed[:fields]
|
|
9
|
+
data = telemetry do
|
|
10
|
+
scope = Telemetry::Deprecation.between(from, to)
|
|
11
|
+
scope = scope.where(gem_name: fields["gem"]) if fields["gem"].present?
|
|
12
|
+
scope = scope.where("message LIKE ?", "%#{Telemetry::Deprecation.sanitize_sql_like(parsed[:text])}%") if parsed[:text].present?
|
|
13
|
+
rows = scope.to_a
|
|
14
|
+
hashes = rows.map(&:group_hash).uniq
|
|
15
|
+
first_ever = Telemetry::Deprecation.where(group_hash: hashes).group(:group_hash).minimum(:occurred_at)
|
|
16
|
+
groups = rows.group_by(&:group_hash)
|
|
17
|
+
.map { |group_hash, group_rows| deprecation_row(group_hash, group_rows, first_ever[group_hash], from, to) }
|
|
18
|
+
.sort_by { |r| -r[:count] }
|
|
19
|
+
{ groups: groups.first(200), distinct: groups.size, total_occurrences: groups.sum { |g| g[:count] },
|
|
20
|
+
new_this_window: groups.count { |g| g[:first_seen_at] && g[:first_seen_at] >= from } }
|
|
21
|
+
end
|
|
22
|
+
render inertia: { deprecations: data[:groups], distinct: data[:distinct], total_occurrences: data[:total_occurrences],
|
|
23
|
+
new_this_window: data[:new_this_window], q: params[:q].to_s }
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def deprecation_row(group_hash, group_rows, first_seen_at, from, to)
|
|
29
|
+
sorted = group_rows.sort_by(&:occurred_at)
|
|
30
|
+
{ group_hash: group_hash, message: sorted.last.message, gem_name: sorted.last.gem_name, horizon: sorted.last.horizon,
|
|
31
|
+
source: sorted.last.source, count: group_rows.size, first_seen_at: first_seen_at, last_seen_at: sorted.last.occurred_at,
|
|
32
|
+
sparkline: hourly_sparkline(group_rows, from, to), occurrences: sorted.reverse.first(20).map { |r| occurrence_row(r) } }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def occurrence_row(r)
|
|
36
|
+
{ id: r.id, occurred_at: r.occurred_at, execution_id: r.execution_id, execution_source: r.execution_source,
|
|
37
|
+
execution_preview: r.execution_preview }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Hourly counts for raw (non-rollup) rows, zero-filled and coarsened to at
|
|
41
|
+
# most 30 points for wide windows -- deprecations have no duration column
|
|
42
|
+
# so they never land in Telemetry::Rollup and can't use
|
|
43
|
+
# EnvironmentScoped#sparkline, which keys off a rollup row's bucket/count.
|
|
44
|
+
def hourly_sparkline(rows, from, to)
|
|
45
|
+
hours = [ ((to - from) / 1.hour).ceil, 1 ].max
|
|
46
|
+
coarsen = (hours / 30.0).ceil
|
|
47
|
+
base = from.beginning_of_hour
|
|
48
|
+
buckets = Hash.new(0)
|
|
49
|
+
rows.each { |r| buckets[((r.occurred_at - base) / 1.hour).to_i / coarsen] += 1 }
|
|
50
|
+
bucket_count = (hours.to_f / coarsen).ceil
|
|
51
|
+
(0...bucket_count).map { |i| buckets[i] }
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Railwatch
|
|
4
|
+
# The platform's EnvironmentScoped concern with the account lookup replaced
|
|
5
|
+
# by the embedded singleton. Everything below the seam is the platform's code.
|
|
6
|
+
module EnvironmentScoped
|
|
7
|
+
extend ActiveSupport::Concern
|
|
8
|
+
|
|
9
|
+
WINDOWS = { "1h" => 1.hour, "6h" => 6.hours, "24h" => 24.hours, "7d" => 7.days, "30d" => 30.days }.freeze
|
|
10
|
+
MAX_CUSTOM_RANGE = 90.days
|
|
11
|
+
|
|
12
|
+
included do
|
|
13
|
+
before_action :set_environment
|
|
14
|
+
inertia_share environment: -> { environment_props }
|
|
15
|
+
inertia_share window: -> { window_key }
|
|
16
|
+
inertia_share range: -> { from, to = window_range; { from: from.iso8601(6), to: to.iso8601(6) } }
|
|
17
|
+
inertia_share saved_views: -> { SavedView.props_for(environment, Viewer.user) }
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
attr_reader :environment
|
|
23
|
+
|
|
24
|
+
def set_environment
|
|
25
|
+
@environment = Environment.find(params[:environment_id] || Environment::ID)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def window_key
|
|
29
|
+
custom_range ? "custom" : (WINDOWS.key?(params[:window].to_s) ? params[:window].to_s : "24h")
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def window_range
|
|
33
|
+
return @window_range if defined?(@window_range)
|
|
34
|
+
@window_range = custom_range || begin
|
|
35
|
+
key = WINDOWS.key?(params[:window].to_s) ? params[:window].to_s : "24h"
|
|
36
|
+
to = Time.current
|
|
37
|
+
[ to - WINDOWS[key], to ]
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def custom_range
|
|
42
|
+
return @custom_range if defined?(@custom_range)
|
|
43
|
+
from = parse_time(params[:from])
|
|
44
|
+
to = from && (parse_time(params[:to]) || Time.current)
|
|
45
|
+
valid = from && to && to > from && (to - from) <= MAX_CUSTOM_RANGE
|
|
46
|
+
@custom_range = valid ? [ from, to ] : nil
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def parse_time(value)
|
|
50
|
+
return nil if value.blank?
|
|
51
|
+
Time.zone.parse(value.to_s)
|
|
52
|
+
rescue ArgumentError, TypeError
|
|
53
|
+
nil
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def previous_window_range
|
|
57
|
+
from, to = window_range
|
|
58
|
+
span = to - from
|
|
59
|
+
[ from - span, from ]
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def telemetry(&block) = environment.with_telemetry(&block)
|
|
63
|
+
|
|
64
|
+
def telemetry_cursor_context(resource)
|
|
65
|
+
from, to = window_range
|
|
66
|
+
FilterQuery.cursor_context(environment_id: environment.id, resource: resource, query: params[:q], window: [ from.iso8601(6), to.iso8601(6) ])
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def application = Application.current
|
|
70
|
+
|
|
71
|
+
def environment_props
|
|
72
|
+
{ id: environment.id, name: environment.name, slug: environment.slug, application_id: application.id,
|
|
73
|
+
application_name: application.name, issue_prefix: application.issue_prefix, last_seen_at: environment.last_seen_at,
|
|
74
|
+
paused: false, token_prefix: environment.token_prefix,
|
|
75
|
+
repository_url: application.repository_url, default_branch: application.default_branch }
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def deploys_in_window
|
|
79
|
+
environment.deploys.between(*window_range).recent.limit(50).map { |d| { deploy: d.deploy, ref: d.short_ref, at: d.deployed_at } }
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def series(record_type, group_hash: nil, name: nil)
|
|
83
|
+
from, to = window_range
|
|
84
|
+
Telemetry::Aggregations.series(environment, record_type, from: from, to: to, group_hash: group_hash, name: name)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def grouped(record_type, limit: 100, order: nil, dir: nil)
|
|
88
|
+
from, to = window_range
|
|
89
|
+
Telemetry::Aggregations.grouped(environment, record_type, from: from, to: to, limit: limit, order: order, dir: dir)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def summary_with_delta(record_type, group_hash: nil)
|
|
93
|
+
from, to = window_range
|
|
94
|
+
previous_from, previous_to = previous_window_range
|
|
95
|
+
Telemetry::Aggregations.summary_with_delta(environment, record_type, from: from, to: to,
|
|
96
|
+
previous_from: previous_from, previous_to: previous_to, group_hash: group_hash)
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Railwatch
|
|
4
|
+
class ExceptionsController < DashboardController
|
|
5
|
+
def index
|
|
6
|
+
from, to = window_range
|
|
7
|
+
page_data = nil
|
|
8
|
+
page = lambda do
|
|
9
|
+
page_data ||= telemetry do
|
|
10
|
+
scope = FilterQuery.apply(Telemetry::Exception.all, resource: :exceptions, query: params[:q], from: from, to: to)
|
|
11
|
+
recent, pagination = Telemetry::CursorPage.call(scope, cursor: params[:cursor], limit: params[:limit],
|
|
12
|
+
context: telemetry_cursor_context(:exceptions))
|
|
13
|
+
issues_by_group = environment.issues.where(group_hash: recent.map(&:group_hash).uniq).index_by(&:group_hash)
|
|
14
|
+
[ recent.map { |e| exception_row(e, issues_by_group) }, pagination ]
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
render inertia: {
|
|
18
|
+
exceptions: InertiaRails.merge { page.call.first }, pagination: -> { page.call.last },
|
|
19
|
+
classes: -> { telemetry { Telemetry::Exception.between(from, to).group(:class_name).count.sort_by { |_class, n| -n }.first(15) } },
|
|
20
|
+
series: -> { telemetry { exception_series(from, to) } },
|
|
21
|
+
total: -> { telemetry { Telemetry::Exception.between(from, to).count } },
|
|
22
|
+
unhandled: -> { telemetry { Telemetry::Exception.between(from, to).unhandled.count } }, q: params[:q].to_s
|
|
23
|
+
}
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def exception_row(e, issues_by_group)
|
|
29
|
+
issue = issues_by_group[e.group_hash]
|
|
30
|
+
{ id: e.id, class_name: e.class_name, message: e.message.first(500), handled: e.handled, severity: e.severity, source: e.source,
|
|
31
|
+
file: e.file, line: e.line, occurred_at: e.occurred_at, execution_id: e.execution_id, execution_source: e.execution_source,
|
|
32
|
+
execution_preview: e.execution_preview, user_ref: e.user_ref, group_hash: e.group_hash, issue_id: issue&.id, issue_key: issue&.key }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Telemetry::Rollup has no "exception" record type (exceptions carry no
|
|
36
|
+
# duration to absorb), so the chart is a plain bucketed count over the raw
|
|
37
|
+
# table instead of the usual EnvironmentScoped#series.
|
|
38
|
+
# `errors` carries the unhandled count so the chart legend (handled /
|
|
39
|
+
# unhandled) agrees with the Unhandled card above the table.
|
|
40
|
+
def exception_series(from, to)
|
|
41
|
+
buckets = Hash.new { |h, k| h[k] = { count: 0, errors: 0 } }
|
|
42
|
+
Telemetry::Exception.between(from, to)
|
|
43
|
+
.group(Arel.sql("strftime('%Y-%m-%dT%H:00:00Z', occurred_at)"), :handled)
|
|
44
|
+
.count
|
|
45
|
+
.each do |(bucket, handled), c|
|
|
46
|
+
buckets[bucket][:count] += c
|
|
47
|
+
buckets[bucket][:errors] += c unless handled
|
|
48
|
+
end
|
|
49
|
+
buckets.map { |bucket, b| { t: bucket, count: b[:count], errors: b[:errors], client_errors: 0, avg: 0, p50: 0, p95: 0, p99: 0 } }
|
|
50
|
+
.sort_by { |r| r[:t] }
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Generic detail route for execution kinds that do not have a dedicated index
|
|
4
|
+
# yet (for example Action Cable channel actions), and for legacy child records
|
|
5
|
+
# whose execution_source was absent.
|
|
6
|
+
module Railwatch
|
|
7
|
+
class ExecutionsController < DashboardController
|
|
8
|
+
def show
|
|
9
|
+
execution = telemetry { Telemetry::Execution.find_by!(execution_id: params[:id]) }
|
|
10
|
+
render inertia: "executions/show", props: ExecutionPresenter.new(execution, environment).props
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Railwatch
|
|
4
|
+
class IssuesController < DashboardController
|
|
5
|
+
BULK_LIMIT = 200
|
|
6
|
+
|
|
7
|
+
before_action :set_issue, only: %i[show update merge_candidates]
|
|
8
|
+
rescue_from Issue::InvalidMerge, with: :invalid_merge
|
|
9
|
+
|
|
10
|
+
def index
|
|
11
|
+
scope = Issue.all
|
|
12
|
+
scope = scope.where(status: params[:status]) if Issue::STATUSES.include?(params[:status].to_s)
|
|
13
|
+
scope = scope.where(status: "open") unless params.key?(:status)
|
|
14
|
+
scope = scope.where(application_id: params[:application_id]) if params[:application_id].present?
|
|
15
|
+
scope = scope.where(environment_id: params[:environment_id]) if params[:environment_id].present?
|
|
16
|
+
scope = scope.where(kind: params[:kind]) if Issue::KINDS.include?(params[:kind].to_s)
|
|
17
|
+
scope = scope.where(assignee_id: Viewer.user.id) if params[:mine] == "1"
|
|
18
|
+
# Same "key:value free text" grammar as the other list pages: today the
|
|
19
|
+
# only key is source, which is how you pull out the browser's own errors.
|
|
20
|
+
parsed = FilterQuery.parse(params[:q])
|
|
21
|
+
scope = scope.where(source: parsed[:fields]["source"]) if parsed[:fields]["source"].present?
|
|
22
|
+
scope = scope.where("title LIKE ?", "%#{Issue.sanitize_sql_like(parsed[:text])}%") if parsed[:text].present?
|
|
23
|
+
render inertia: { issues: scope.recent.limit(200).map { |i| row(i) },
|
|
24
|
+
filters: params.permit(:status, :application_id, :environment_id, :kind, :mine, :q).to_h,
|
|
25
|
+
counts: Issue.all.group(:status).count,
|
|
26
|
+
kindCounts: Issue.all.open.group(:kind).count,
|
|
27
|
+
members: User.default.then { |u| [ { id: u.id, name: u.name } ] } }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def show
|
|
31
|
+
env = @issue.environment
|
|
32
|
+
return render_detection_issue(env) unless @issue.kind == "exception"
|
|
33
|
+
|
|
34
|
+
from = 30.days.ago
|
|
35
|
+
occurrences = env.with_telemetry do
|
|
36
|
+
Telemetry::Exception.where(group_hash: @issue.group_hash).between(from, Time.current).recent.limit(50)
|
|
37
|
+
.map { |e| { id: e.id, message: e.message.first(300), handled: e.handled, occurred_at: e.occurred_at, execution_id: e.execution_id, execution_source: e.execution_source, execution_preview: e.execution_preview, user_ref: e.user_ref, tenant: e.app_tenant, deploy: e.deploy, server: e.server } }
|
|
38
|
+
end
|
|
39
|
+
latest = env.with_telemetry { Telemetry::Exception.where(group_hash: @issue.group_hash).recent.first }
|
|
40
|
+
browser_breadcrumbs = browser_breadcrumbs_for(latest)
|
|
41
|
+
breadcrumbs = latest&.execution_id ? env.with_telemetry { breadcrumbs_for(latest) } : []
|
|
42
|
+
daily_counts = env.with_telemetry { Telemetry::Exception.where(group_hash: @issue.group_hash).between(from, Time.current).group(Arel.sql("date(occurred_at)")).count }.transform_keys(&:to_s)
|
|
43
|
+
daily = (from.to_date..Date.current).map { |d| { day: d.to_s, count: daily_counts[d.to_s].to_i } }
|
|
44
|
+
by_deploy = env.with_telemetry { Telemetry::Exception.where(group_hash: @issue.group_hash).between(from, Time.current).group(:deploy).count }
|
|
45
|
+
by_tenant = env.with_telemetry { Telemetry::Exception.where(group_hash: @issue.group_hash).between(from, Time.current).where.not(app_tenant: nil).group(:app_tenant).count }
|
|
46
|
+
attachments = env.with_telemetry { attachments_for_issue }
|
|
47
|
+
# What a split by message would produce, and what the fingerprint that
|
|
48
|
+
# grouped these occurrences was -- both drive the Grouping panel.
|
|
49
|
+
messages = env.with_telemetry { Telemetry::Exception.where(group_hash: @issue.group_hash).recent.limit(Issue::SPLIT_SAMPLE).pluck(:message) }.tally
|
|
50
|
+
render inertia: {
|
|
51
|
+
issue: row(@issue).merge(first_seen_at: @issue.first_seen_at, resolved_at: @issue.resolved_at, resolved_in_deploy: @issue.resolved_in_deploy, regressed_at: @issue.regressed_at, sample: @issue.sample),
|
|
52
|
+
detection: nil,
|
|
53
|
+
latest: latest && { id: latest.id, class_name: latest.class_name, message: latest.message, handled: latest.handled, severity: latest.severity, source: latest.source, file: latest.file, line: latest.line, frames: latest.frames, cause: latest.cause, locals: latest.locals, context: context_without_breadcrumbs(latest), ruby_version: latest.ruby_version, rails_version: latest.rails_version, execution_id: latest.execution_id, execution_source: latest.execution_source, execution_preview: latest.execution_preview, deploy: latest.deploy },
|
|
54
|
+
breadcrumbs: breadcrumbs, browser_breadcrumbs: browser_breadcrumbs,
|
|
55
|
+
fingerprint: (latest&.fingerprint).presence || Array(@issue.sample["fingerprint"]),
|
|
56
|
+
fingerprint_source: latest&.fingerprint_source || @issue.sample["fingerprint_source"],
|
|
57
|
+
distinct_messages: messages.size,
|
|
58
|
+
top_messages: messages.sort_by { |_, n| -n }.first(5).map { |message, n| { message: message.to_s.first(200), count: n } },
|
|
59
|
+
occurrences: occurrences, daily: daily, by_deploy: by_deploy, by_tenant: by_tenant, attachments: attachments,
|
|
60
|
+
comments: @issue.comments.order(:created_at).map { |c| { id: c.id, body: c.body, user: c.user&.name || c.author_name || "Linear", source: c.source, created_at: c.created_at } },
|
|
61
|
+
activities: @issue.activities.order(:created_at).map { |a| activity_row(a) },
|
|
62
|
+
members: User.default.then { |u| [ { id: u.id, name: u.name } ] },
|
|
63
|
+
deploys: env.deploys.recent.limit(20).map { |d| { deploy: d.deploy, ref: d.short_ref, at: d.deployed_at } },
|
|
64
|
+
related_issues: related_issues,
|
|
65
|
+
merged_into: @issue.merged_into && { id: @issue.merged_into.id, key: @issue.merged_into.key, title: @issue.merged_into.title },
|
|
66
|
+
merged_issues: @issue.merged_issues.map { |i| { id: i.id, key: i.key, title: i.title } },
|
|
67
|
+
alerts: @issue.alerts.order(created_at: :desc).map { |a| alert_row(a) }
|
|
68
|
+
}
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def merge_candidates
|
|
72
|
+
query = params[:q].to_s.strip.first(200)
|
|
73
|
+
scope = @issue.environment.issues.open.where.not(id: @issue.id)
|
|
74
|
+
if query.present?
|
|
75
|
+
pattern = "%#{Issue.sanitize_sql_like(query)}%"
|
|
76
|
+
key_match = query.upcase.match(/\A#{Regexp.escape(@issue.application.issue_prefix)}-(\d+)\z/)
|
|
77
|
+
scope = if key_match
|
|
78
|
+
scope.where("title LIKE :pattern OR number = :number", pattern: pattern, number: key_match[1].to_i)
|
|
79
|
+
else
|
|
80
|
+
scope.where("title LIKE ?", pattern)
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
render json: { issues: scope.order(last_seen_at: :desc, id: :desc).limit(20)
|
|
85
|
+
.map { |issue| { id: issue.id, key: issue.key, title: issue.title } } }
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def update
|
|
89
|
+
notice = "Issue updated"
|
|
90
|
+
case params[:action_name]
|
|
91
|
+
when "resolve" then @issue.resolve!(deploy: params[:deploy].presence || @issue.environment.deploys.recent.first&.deploy)
|
|
92
|
+
when "ignore" then @issue.ignore!
|
|
93
|
+
when "reopen" then @issue.reopen!
|
|
94
|
+
when "assign" then @issue.update!(assignee: assignee_from(params[:assignee_id]))
|
|
95
|
+
when "assign_me" then @issue.update!(assignee_id: Viewer.user.id)
|
|
96
|
+
when "priority" then @issue.update!(priority: params[:priority])
|
|
97
|
+
when "merge" then @issue.merge_into!(@issue.environment.issues.open.find(params[:target_id]))
|
|
98
|
+
when "unmerge" then @issue.unmerge!
|
|
99
|
+
when "split" then notice = split_notice(@issue.split!(by: params[:by].to_s))
|
|
100
|
+
else @issue.update!(params.permit(:title))
|
|
101
|
+
end
|
|
102
|
+
redirect_to issue_path(@issue), notice: notice
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def bulk
|
|
106
|
+
ids = Array(params[:ids]).first(BULK_LIMIT)
|
|
107
|
+
scope = Issue.all.where(id: ids)
|
|
108
|
+
if params[:action_name] == "merge"
|
|
109
|
+
bulk_merge(scope)
|
|
110
|
+
else
|
|
111
|
+
scope.find_each do |issue|
|
|
112
|
+
case params[:action_name]
|
|
113
|
+
when "resolve" then issue.resolve!(deploy: issue.environment.deploys.recent.first&.deploy)
|
|
114
|
+
when "ignore" then issue.ignore!
|
|
115
|
+
when "priority" then issue.update!(priority: params[:value])
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
redirect_back fallback_location: issues_path, notice: "#{ids.size} issue(s) updated"
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
private
|
|
123
|
+
|
|
124
|
+
# A stale selection can contain issues that have already been merged since
|
|
125
|
+
# the list rendered. Ignore those sources, choose an open target, and wrap
|
|
126
|
+
# the whole operation so a concurrent state change cannot partially fold a
|
|
127
|
+
# bulk selection before the model rejects it.
|
|
128
|
+
def bulk_merge(scope)
|
|
129
|
+
issues = scope.order(:first_seen_at, :id).to_a
|
|
130
|
+
target = issues.find(&:open?)
|
|
131
|
+
return unless target
|
|
132
|
+
|
|
133
|
+
Issue.transaction do
|
|
134
|
+
issues.each do |issue|
|
|
135
|
+
next if issue.id == target.id || issue.environment_id != target.environment_id || issue.merged?
|
|
136
|
+
|
|
137
|
+
issue.merge_into!(target)
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def invalid_merge(error)
|
|
143
|
+
redirect_back fallback_location: (@issue ? issue_path(@issue) : issues_path), alert: error.message
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def render_detection_issue(env)
|
|
147
|
+
render inertia: {
|
|
148
|
+
issue: row(@issue).merge(first_seen_at: @issue.first_seen_at, resolved_at: @issue.resolved_at,
|
|
149
|
+
resolved_in_deploy: @issue.resolved_in_deploy, regressed_at: @issue.regressed_at, sample: @issue.sample),
|
|
150
|
+
detection: IssueDetectionPresenter.new(@issue).as_json,
|
|
151
|
+
latest: nil, breadcrumbs: [], browser_breadcrumbs: [], fingerprint: [], fingerprint_source: nil,
|
|
152
|
+
distinct_messages: 0, top_messages: [], occurrences: [], daily: [], by_deploy: {}, by_tenant: {}, attachments: [],
|
|
153
|
+
comments: @issue.comments.order(:created_at).map { |c| { id: c.id, body: c.body, user: c.user.name, created_at: c.created_at } },
|
|
154
|
+
activities: @issue.activities.order(:created_at).map { |a| activity_row(a) },
|
|
155
|
+
members: User.default.then { |u| [ { id: u.id, name: u.name } ] },
|
|
156
|
+
deploys: env.deploys.recent.limit(20).map { |d| { deploy: d.deploy, ref: d.short_ref, at: d.deployed_at } },
|
|
157
|
+
related_issues: related_issues,
|
|
158
|
+
merged_into: @issue.merged_into && { id: @issue.merged_into.id, key: @issue.merged_into.key, title: @issue.merged_into.title },
|
|
159
|
+
merged_issues: @issue.merged_issues.map { |i| { id: i.id, key: i.key, title: i.title } },
|
|
160
|
+
alerts: @issue.alerts.order(created_at: :desc).map { |a| alert_row(a) }
|
|
161
|
+
}
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Sentry-style breadcrumbs: everything the execution did before it raised
|
|
165
|
+
# (queries, cache, outgoing calls, logs, jobs), newest last, capped at 30.
|
|
166
|
+
def breadcrumbs_for(exception)
|
|
167
|
+
exe = Telemetry::Execution.find_by(execution_id: exception.execution_id) or return []
|
|
168
|
+
ExecutionPresenter.new(exe, @issue.environment).timeline_entries
|
|
169
|
+
.select { |e| e[:offset] <= ((exception.occurred_at - exe.occurred_at) * 1000.0) + 1 }
|
|
170
|
+
.reject { |e| e[:type] == "exception" && e[:id] == exception.id }
|
|
171
|
+
.last(30)
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# A browser exception's execution is the beacon POST that carried it, whose
|
|
175
|
+
# timeline says nothing about the crash. What it has instead is the trail
|
|
176
|
+
# the browser client recorded -- console errors, clicks, navigations --
|
|
177
|
+
# which the gem stores in the exception's context.
|
|
178
|
+
def browser_breadcrumbs_for(exception)
|
|
179
|
+
trail = browser_context(exception)["breadcrumbs"]
|
|
180
|
+
return [] unless trail.is_a?(Array)
|
|
181
|
+
trail.filter_map { |crumb| crumb.slice("at", "kind", "text") if crumb.is_a?(Hash) }
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# The same context with the trail taken back out, since the page renders it
|
|
185
|
+
# as a breadcrumb list rather than as raw JSON in the context panel.
|
|
186
|
+
def context_without_breadcrumbs(exception)
|
|
187
|
+
context = parsed_context(exception)
|
|
188
|
+
return exception.context unless context["browser"].is_a?(Hash) && context["browser"].key?("breadcrumbs")
|
|
189
|
+
context.merge("browser" => context["browser"].except("breadcrumbs")).to_json
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def browser_context(exception)
|
|
193
|
+
context = parsed_context(exception)
|
|
194
|
+
context["browser"].is_a?(Hash) ? context["browser"] : {}
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# Exception context is a JSON string the gem built; anything else in that
|
|
198
|
+
# column is an older or garbled record, and reads as empty rather than 500.
|
|
199
|
+
def parsed_context(exception)
|
|
200
|
+
parsed = JSON.parse(exception&.context.to_s)
|
|
201
|
+
parsed.is_a?(Hash) ? parsed : {}
|
|
202
|
+
rescue JSON::ParserError
|
|
203
|
+
{}
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# Files the app attached while raising this exception, from any execution.
|
|
207
|
+
# `data` is left out of the select: it is the whole gzipped file and the
|
|
208
|
+
# page only links to the download.
|
|
209
|
+
def attachments_for_issue
|
|
210
|
+
Telemetry::Attachment.where(exception_group_hash: @issue.group_hash).recent.limit(20)
|
|
211
|
+
.select(:id, :name, :content_type, :bytes, :truncated, :occurred_at, :execution_id, :execution_preview)
|
|
212
|
+
.map { |a| { id: a.id, name: a.name, content_type: a.content_type, bytes: a.bytes, truncated: a.truncated,
|
|
213
|
+
viewable: a.viewable?, occurred_at: a.occurred_at, execution_id: a.execution_id, execution_preview: a.execution_preview } }
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# split! no-ops when there is nothing to split apart (the button is hidden
|
|
217
|
+
# then, but a stale page can still ask), so say which one happened.
|
|
218
|
+
def split_notice(created)
|
|
219
|
+
created.empty? ? "Nothing to split \u2014 every recent occurrence is already in its own issue" : "Split into #{created.size} issue(s)"
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def set_issue
|
|
223
|
+
@issue = Issue.all.find(params[:id])
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# Only a member of the current account can be assigned; nil clears.
|
|
227
|
+
def assignee_from(id)
|
|
228
|
+
id.present? ? User.find(id) : nil
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
# Same exception class seen in other environments of the same application:
|
|
232
|
+
# issue titles are stored as "ClassName: message", so we match on the
|
|
233
|
+
# "ClassName:" prefix.
|
|
234
|
+
def related_issues
|
|
235
|
+
class_name = @issue.title.to_s.split(":", 2).first
|
|
236
|
+
return [] if class_name.blank?
|
|
237
|
+
Issue.all.where.not(id: @issue.id)
|
|
238
|
+
.where("title LIKE ?", "#{Issue.sanitize_sql_like(class_name)}:%").limit(20)
|
|
239
|
+
.map { |i| { id: i.id, key: i.key, title: i.title, status: i.status, environment: { id: i.environment.id, name: i.environment.name } } }
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def row(i)
|
|
243
|
+
{ id: i.id, key: i.key, title: i.title, kind: i.kind, status: i.status, priority: i.priority, culprit: i.culprit, occurrences: i.occurrences,
|
|
244
|
+
affected_users: i.affected_users, first_seen_at: i.first_seen_at, last_seen_at: i.last_seen_at, assignee: i.assignee && { id: i.assignee.id, name: i.assignee.name },
|
|
245
|
+
fingerprint_source: i.sample["fingerprint_source"], source: i.source,
|
|
246
|
+
application: { id: i.application.id, name: i.application.name }, environment: { id: i.environment.id, name: i.environment.name } }
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def activity_row(a)
|
|
250
|
+
{ id: a.id, kind: a.kind, data: a.data, user: a.user && { id: a.user.id, name: a.user.name }, created_at: a.created_at }
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def alert_row(a)
|
|
254
|
+
{ id: a.id, event: a.event, status: a.status, sent_at: a.sent_at, error: a.error,
|
|
255
|
+
integration: { kind: a.integration.kind, name: a.integration.name } }
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Railwatch
|
|
4
|
+
class JobsController < DashboardController
|
|
5
|
+
include TelemetryIdentity
|
|
6
|
+
|
|
7
|
+
def index
|
|
8
|
+
from, to = window_range
|
|
9
|
+
page_data = nil
|
|
10
|
+
page = lambda do
|
|
11
|
+
page_data ||= telemetry do
|
|
12
|
+
scope = FilterQuery.apply(Telemetry::Execution.jobs, resource: :jobs, query: params[:q], from: from, to: to)
|
|
13
|
+
rows, meta = Telemetry::CursorPage.call(scope, cursor: params[:cursor], limit: params[:limit],
|
|
14
|
+
context: telemetry_cursor_context(:jobs))
|
|
15
|
+
people = origin_people(rows)
|
|
16
|
+
[ rows.map { |r| job_row(r, people) }, meta ]
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
render inertia: {
|
|
20
|
+
classes: -> { grouped("job_attempt", limit: 200) }, series: -> { series("job_attempt") },
|
|
21
|
+
queues: -> { queue_stats(from, to) }, recent: InertiaRails.merge { page.call.first },
|
|
22
|
+
pagination: -> { page.call.last }, deploys: -> { deploys_in_window }, q: params[:q].to_s
|
|
23
|
+
}
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def klass
|
|
27
|
+
group_hash = params[:group_hash]
|
|
28
|
+
from, to = window_range
|
|
29
|
+
name, attempts = telemetry do
|
|
30
|
+
base = Telemetry::Execution.jobs.where(group_hash: group_hash)
|
|
31
|
+
rows = FilterQuery.apply(base, resource: :jobs,
|
|
32
|
+
query: params[:q], from: from, to: to).recent.limit(200).to_a
|
|
33
|
+
people = origin_people(rows)
|
|
34
|
+
[ base.between(from, to).pick(:name), rows.map { |row| job_row(row, people) } ]
|
|
35
|
+
end
|
|
36
|
+
render inertia: "jobs/klass", props: { name: name, group_hash: group_hash,
|
|
37
|
+
summary: summary_with_delta("job_attempt", group_hash: group_hash),
|
|
38
|
+
series: series("job_attempt", group_hash: group_hash), attempts: attempts,
|
|
39
|
+
deploys: deploys_in_window, q: params[:q].to_s }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def show
|
|
43
|
+
exe = telemetry { Telemetry::Execution.find_by!(execution_id: params[:id]) }
|
|
44
|
+
render inertia: "executions/show", props: ExecutionPresenter.new(exe, environment).props
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def queue_stats(from, to)
|
|
50
|
+
telemetry do
|
|
51
|
+
Telemetry::Execution.jobs.between(from, to).group(:queue)
|
|
52
|
+
.pluck(:queue, Arel.sql("COUNT(*)"), Arel.sql("AVG(queue_latency)"), Arel.sql("SUM(CASE WHEN outcome = 'failed' THEN 1 ELSE 0 END)"))
|
|
53
|
+
.map { |q, c, lat, f| { queue: q, count: c, avg_latency: (lat.to_f / 1000.0).round(1), failed: f } }
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def job_row(r, people)
|
|
58
|
+
{ execution_id: r.execution_id, name: r.name, outcome: r.outcome, queue: r.queue, attempt: r.attempt, duration: r.duration_ms.round(2),
|
|
59
|
+
queue_latency: r.queue_latency && (r.queue_latency / 1000.0).round(1), occurred_at: r.occurred_at, exception_preview: r.exception_preview,
|
|
60
|
+
job_id: r.job_id, deploy: r.deploy }.merge(origin_identity(r, people))
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|