railwatch 0.1.4 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +212 -0
- data/README.md +10 -0
- data/app/channels/railwatch/environment_channel.rb +28 -0
- data/app/controllers/concerns/railwatch/telemetry_identity.rb +25 -0
- data/app/controllers/railwatch/alerts_controller.rb +48 -0
- data/app/controllers/railwatch/anomaly_rules_controller.rb +31 -0
- data/app/controllers/railwatch/attachments_controller.rb +22 -0
- data/app/controllers/railwatch/beacon_controller.rb +67 -7
- data/app/controllers/railwatch/broadcasts_controller.rb +15 -0
- data/app/controllers/railwatch/cache_events_controller.rb +30 -0
- data/app/controllers/railwatch/commands_controller.rb +16 -0
- data/app/controllers/railwatch/comments_controller.rb +11 -0
- data/app/controllers/railwatch/dashboard_controller.rb +69 -0
- data/app/controllers/railwatch/deploys_controller.rb +34 -0
- data/app/controllers/railwatch/deprecations_controller.rb +54 -0
- data/app/controllers/railwatch/environment_scoped.rb +99 -0
- data/app/controllers/railwatch/exceptions_controller.rb +53 -0
- data/app/controllers/railwatch/executions_controller.rb +13 -0
- data/app/controllers/railwatch/issues_controller.rb +258 -0
- data/app/controllers/railwatch/jobs_controller.rb +63 -0
- data/app/controllers/railwatch/llm_calls_controller.rb +124 -0
- data/app/controllers/railwatch/logs_controller.rb +40 -0
- data/app/controllers/railwatch/mails_controller.rb +19 -0
- data/app/controllers/railwatch/notifications_controller.rb +11 -0
- data/app/controllers/railwatch/outgoing_requests_controller.rb +19 -0
- data/app/controllers/railwatch/overview_controller.rb +38 -0
- data/app/controllers/railwatch/people_controller.rb +25 -0
- data/app/controllers/railwatch/processes_controller.rb +33 -0
- data/app/controllers/railwatch/profiles_controller.rb +50 -0
- data/app/controllers/railwatch/queries_controller.rb +59 -0
- data/app/controllers/railwatch/releases_controller.rb +75 -0
- data/app/controllers/railwatch/requests_controller.rb +55 -0
- data/app/controllers/railwatch/saved_views_controller.rb +53 -0
- data/app/controllers/railwatch/scheduled_tasks_controller.rb +45 -0
- data/app/controllers/railwatch/spans_controller.rb +47 -0
- data/app/controllers/railwatch/storage_ops_controller.rb +15 -0
- data/app/controllers/railwatch/tenants_controller.rb +44 -0
- data/app/controllers/railwatch/thresholds_controller.rb +40 -0
- data/app/controllers/railwatch/traces_controller.rb +72 -0
- data/app/controllers/railwatch/transactions_controller.rb +21 -0
- data/app/controllers/railwatch/view_renders_controller.rb +28 -0
- data/app/controllers/railwatch/visits_controller.rb +53 -0
- data/app/helpers/railwatch/assets_helper.rb +52 -0
- data/app/jobs/railwatch/anomaly_scan_job.rb +11 -0
- data/app/jobs/railwatch/application_job.rb +7 -0
- data/app/jobs/railwatch/auto_resolve_issues_job.rb +19 -0
- data/app/jobs/railwatch/check_scheduled_tasks_job.rb +113 -0
- data/app/jobs/railwatch/detect_anomalies_job.rb +171 -0
- data/app/jobs/railwatch/detect_performance_issues_job.rb +85 -0
- data/app/jobs/railwatch/group_exceptions_job.rb +91 -0
- data/app/jobs/railwatch/optimize_telemetry_job.rb +23 -0
- data/app/jobs/railwatch/performance_scan_job.rb +11 -0
- data/app/jobs/railwatch/prune_telemetry_job.rb +110 -0
- data/app/jobs/railwatch/release_health_rollup_job.rb +64 -0
- data/app/jobs/railwatch/rollup_catchup_job.rb +21 -0
- data/app/jobs/railwatch/rollup_job.rb +130 -0
- data/app/jobs/railwatch/scheduled_task_scan_job.rb +11 -0
- data/app/models/concerns/railwatch/detection_snapshotting.rb +20 -0
- data/app/models/railwatch/alert.rb +229 -0
- data/app/models/railwatch/alert_rule.rb +121 -0
- data/app/models/railwatch/anomaly_rule.rb +33 -0
- data/app/models/railwatch/application.rb +44 -0
- data/app/models/railwatch/application_record.rb +33 -0
- data/app/models/railwatch/comment.rb +36 -0
- data/app/models/railwatch/deploy.rb +67 -0
- data/app/models/railwatch/environment.rb +54 -0
- data/app/models/railwatch/execution_presenter.rb +185 -0
- data/app/models/railwatch/filter_query.rb +143 -0
- data/app/models/railwatch/followup_receipt.rb +27 -0
- data/app/models/railwatch/ingest/batch.rb +305 -0
- data/app/models/railwatch/ingest/mapper.rb +575 -0
- data/app/models/railwatch/ingest/payload.rb +96 -0
- data/app/models/railwatch/ingest/rollup_absorber.rb +170 -0
- data/app/models/railwatch/ingest/writer.rb +137 -0
- data/app/models/railwatch/issue.rb +277 -0
- data/app/models/railwatch/issue_activity.rb +23 -0
- data/app/models/railwatch/issue_detection_presenter.rb +309 -0
- data/app/models/railwatch/issue_detection_snapshot.rb +77 -0
- data/app/models/railwatch/maintenance_task.rb +53 -0
- data/app/models/railwatch/saved_view.rb +63 -0
- data/app/models/railwatch/telemetry/aggregations.rb +116 -0
- data/app/models/railwatch/telemetry/attachment.rb +31 -0
- data/app/models/railwatch/telemetry/bounded_gzip.rb +101 -0
- data/app/models/railwatch/telemetry/broadcast.rb +13 -0
- data/app/models/railwatch/telemetry/cache_event.rb +16 -0
- data/app/models/railwatch/telemetry/child.rb +53 -0
- data/app/models/railwatch/telemetry/cursor_page.rb +99 -0
- data/app/models/railwatch/telemetry/deprecation.rb +13 -0
- data/app/models/railwatch/telemetry/enqueued_job.rb +13 -0
- data/app/models/railwatch/telemetry/exception.rb +21 -0
- data/app/models/railwatch/telemetry/execution.rb +71 -0
- data/app/models/railwatch/telemetry/health_sample.rb +72 -0
- data/app/models/railwatch/telemetry/ingest_batch.rb +31 -0
- data/app/models/railwatch/telemetry/llm_call.rb +37 -0
- data/app/models/railwatch/telemetry/log.rb +85 -0
- data/app/models/railwatch/telemetry/mail.rb +13 -0
- data/app/models/railwatch/telemetry/n_plus_one.rb +92 -0
- data/app/models/railwatch/telemetry/notification.rb +13 -0
- data/app/models/railwatch/telemetry/outgoing_request.rb +13 -0
- data/app/models/railwatch/telemetry/person.rb +50 -0
- data/app/models/railwatch/telemetry/process.rb +10 -0
- data/app/models/railwatch/telemetry/profile.rb +37 -0
- data/app/models/railwatch/telemetry/query.rb +50 -0
- data/app/models/railwatch/telemetry/query_shape.rb +45 -0
- data/app/models/railwatch/telemetry/release_health.rb +63 -0
- data/app/models/railwatch/telemetry/rollup.rb +78 -0
- data/app/models/railwatch/telemetry/session.rb +24 -0
- data/app/models/railwatch/telemetry/span.rb +19 -0
- data/app/models/railwatch/telemetry/storage_op.rb +13 -0
- data/app/models/railwatch/telemetry/tenant.rb +221 -0
- data/app/models/railwatch/telemetry/transaction.rb +13 -0
- data/app/models/railwatch/telemetry/view_render.rb +13 -0
- data/app/models/railwatch/telemetry/visit.rb +24 -0
- data/app/models/railwatch/telemetry_record.rb +57 -0
- data/app/models/railwatch/threshold.rb +29 -0
- data/app/models/railwatch/user.rb +47 -0
- data/app/models/railwatch/viewer.rb +13 -0
- data/app/views/layouts/railwatch/dashboard.html.erb +25 -0
- data/config/routes.rb +59 -0
- data/db/railwatch_migrate/20260916000000_create_railwatch_tables.rb +151 -0
- data/db/railwatch_migrate/20260917000000_create_railwatch_maintenance_tasks.rb +18 -0
- data/db/railwatch_migrate/20260917120000_create_railwatch_followup_receipts.rb +20 -0
- data/db/railwatch_migrate/20260918120000_widen_host_user_ids.rb +71 -0
- data/db/railwatch_telemetry_migrate/20260903000001_create_telemetry.rb +481 -0
- data/db/railwatch_telemetry_migrate/20260903000002_rename_tenant_to_app_tenant.rb +14 -0
- data/db/railwatch_telemetry_migrate/20260903000003_add_statement_count_to_transactions.rb +9 -0
- data/db/railwatch_telemetry_migrate/20260903000004_add_role_and_channel.rb +10 -0
- data/db/railwatch_telemetry_migrate/20260903000005_add_locals_to_exceptions.rb +9 -0
- data/db/railwatch_telemetry_migrate/20260903000006_add_spans_health_vitals_and_fts.rb +82 -0
- data/db/railwatch_telemetry_migrate/20260903000007_rename_span_attributes_to_payload.rb +10 -0
- data/db/railwatch_telemetry_migrate/20260903000008_add_profiles_and_attachments.rb +60 -0
- data/db/railwatch_telemetry_migrate/20260903000009_add_truncated_to_attachments.rb +9 -0
- data/db/railwatch_telemetry_migrate/20260903000010_create_sessions_and_release_health.rb +51 -0
- data/db/railwatch_telemetry_migrate/20260903000011_add_fingerprint_to_exceptions.rb +11 -0
- data/db/railwatch_telemetry_migrate/20260904000012_add_failed_to_broadcasts.rb +10 -0
- data/db/railwatch_telemetry_migrate/20260904010000_add_filter_cursor_indexes.rb +37 -0
- data/db/railwatch_telemetry_migrate/20260904020000_add_n_plus_ones_execution_id_index.rb +11 -0
- data/db/railwatch_telemetry_migrate/20260904120000_create_query_shapes.rb +15 -0
- data/db/railwatch_telemetry_migrate/20260906120000_add_backpressure_factor_to_ingest_batches.rb +9 -0
- data/db/railwatch_telemetry_migrate/20260907000000_rename_lantern_version_on_processes.rb +10 -0
- data/db/railwatch_telemetry_migrate/20260913000000_rename_nightrail_version_on_processes.rb +9 -0
- data/db/railwatch_telemetry_migrate/20260914000000_drop_orphan_durable_ingest_tables.rb +18 -0
- data/db/railwatch_telemetry_migrate/20260914010000_drop_orphan_durable_ingest_columns.rb +25 -0
- data/db/railwatch_telemetry_migrate/20260915000000_create_llm_calls.rb +55 -0
- data/db/railwatch_telemetry_migrate/20260915120000_add_detail_to_llm_calls.rb +21 -0
- data/db/railwatch_telemetry_migrate/20260915200000_add_explained_index_to_queries.rb +16 -0
- data/db/railwatch_telemetry_migrate/20260915210000_add_slowest_index_to_queries.rb +18 -0
- data/db/railwatch_telemetry_migrate/20260917010000_add_batch_ledger_to_ingest_batches.rb +16 -0
- data/docs/configuration.md +26 -0
- data/docs/embedded.md +352 -0
- data/docs/getting-started.md +5 -0
- data/lib/generators/railwatch/install/install_generator.rb +222 -1
- data/lib/generators/railwatch/install/templates/{initializer.rb → initializer.rb.tt} +39 -0
- data/lib/generators/railwatch/install/templates/post-deploy +8 -0
- data/lib/puma/plugin/railwatch.rb +170 -0
- data/lib/railwatch/authentication.rb +83 -0
- data/lib/railwatch/configuration.rb +134 -3
- data/lib/railwatch/dashboard_assets.rb +45 -0
- data/lib/railwatch/embedded.rb +54 -0
- data/lib/railwatch/engine.rb +89 -1
- data/lib/railwatch/ingest_request_body_limit.rb +10 -0
- data/lib/railwatch/json_compat.rb +60 -0
- data/lib/railwatch/maintenance.rb +183 -0
- data/lib/railwatch/patches/runner_command.rb +21 -1
- data/lib/railwatch/record.rb +33 -8
- data/lib/railwatch/reporter.rb +16 -0
- data/lib/railwatch/subscribers/process_info.rb +2 -1
- data/lib/railwatch/transport/local.rb +78 -0
- data/lib/railwatch/transport/socket.rb +183 -0
- data/lib/railwatch/version.rb +1 -1
- data/lib/railwatch/writer.rb +370 -0
- data/lib/railwatch.rb +38 -1
- data/lib/tasks/railwatch_tasks.rake +128 -0
- data/public/railwatch/assets/CommitMono-Bold-D6h61ieg.woff2 +0 -0
- data/public/railwatch/assets/CommitMono-Regular-zr8w7Obm.woff2 +0 -0
- data/public/railwatch/assets/Roboto-Black-auA4GeOK.woff2 +0 -0
- data/public/railwatch/assets/Roboto-Bold-CJLnO8j1.woff2 +0 -0
- data/public/railwatch/assets/Roboto-Medium-Cm2bwKpj.woff2 +0 -0
- data/public/railwatch/assets/Roboto-Regular-Chaq1-PV.woff2 +0 -0
- data/public/railwatch/assets/app-layout-yh-sPWgK.js +1 -0
- data/public/railwatch/assets/app-wordmark-nbjkzxwQ.js +1 -0
- data/public/railwatch/assets/appearance-CDuRvQTB.js +1 -0
- data/public/railwatch/assets/application-C_kpBdnf.css +1 -0
- data/public/railwatch/assets/arrow-up-DVtOGdVA.js +1 -0
- data/public/railwatch/assets/auth-layout-TCwPpS1K.js +1 -0
- data/public/railwatch/assets/badge-Daw4Hvr8.js +1 -0
- data/public/railwatch/assets/braces-DhFbHPsz.js +1 -0
- data/public/railwatch/assets/card-BX_3HXcJ.js +1 -0
- data/public/railwatch/assets/chart-B14-N9g7.js +39 -0
- data/public/railwatch/assets/chart-hover-Vy52H4uD.js +1 -0
- data/public/railwatch/assets/chart-panel-Cb4S_ej_.js +1 -0
- data/public/railwatch/assets/checkbox-D3aRSsBj.js +1 -0
- data/public/railwatch/assets/code-KvW8k7Jr.js +1 -0
- data/public/railwatch/assets/copy-DaQMJWoT.js +1 -0
- data/public/railwatch/assets/copy-block-BKFGd11J.js +1 -0
- data/public/railwatch/assets/copy-id-Djks1fXB.js +1 -0
- data/public/railwatch/assets/cursor-load-more-BXV0f1D_.js +1 -0
- data/public/railwatch/assets/data-table-iv1bdF6u.js +1 -0
- data/public/railwatch/assets/edit-BZc_Iawe.js +1 -0
- data/public/railwatch/assets/edit-DMKUF8Zi.js +8 -0
- data/public/railwatch/assets/edit-__9yJlO3.js +1 -0
- data/public/railwatch/assets/empty-state-6j_0AaQQ.js +1 -0
- data/public/railwatch/assets/env-layout-DrT8rO6P.js +1 -0
- data/public/railwatch/assets/execution-path-CzgBUi5e.js +1 -0
- data/public/railwatch/assets/filter-bar-9SU5NrzX.js +1 -0
- data/public/railwatch/assets/flamegraph-qcekju8V.js +2 -0
- data/public/railwatch/assets/format-B9SDkrWj.js +1 -0
- data/public/railwatch/assets/frames-Cyu7KMxZ.js +1 -0
- data/public/railwatch/assets/google-sign-in-button-DsTSfmzY.js +1 -0
- data/public/railwatch/assets/index-1ol1-QWI.js +1 -0
- data/public/railwatch/assets/index-5jI4aFzC.js +1 -0
- data/public/railwatch/assets/index-9KTrVnrc.js +1 -0
- data/public/railwatch/assets/index-B0-8lcTp.js +1 -0
- data/public/railwatch/assets/index-B7jjfNfO.js +1 -0
- data/public/railwatch/assets/index-BBchRy0M.js +1 -0
- data/public/railwatch/assets/index-BRiq3SNR.js +1 -0
- data/public/railwatch/assets/index-BgKj9xhr.js +1 -0
- data/public/railwatch/assets/index-BkTZqqOu.js +1 -0
- data/public/railwatch/assets/index-BprKx8QO.js +1 -0
- data/public/railwatch/assets/index-C3jzvPs3.js +1 -0
- data/public/railwatch/assets/index-Cbs6gGyQ.js +1 -0
- data/public/railwatch/assets/index-CdRZ6AWF.js +1 -0
- data/public/railwatch/assets/index-CeYKnapu.js +1 -0
- data/public/railwatch/assets/index-Cmlwy1-V.js +1 -0
- data/public/railwatch/assets/index-Cwx6058d.js +1 -0
- data/public/railwatch/assets/index-D4CSdbHv.js +1 -0
- data/public/railwatch/assets/index-DEFMSkdG.js +1 -0
- data/public/railwatch/assets/index-DFiHEBSh.js +1 -0
- data/public/railwatch/assets/index-DL4vWdWJ.js +1 -0
- data/public/railwatch/assets/index-DU9F5b5d.js +1 -0
- data/public/railwatch/assets/index-DaXgPcGL.js +1 -0
- data/public/railwatch/assets/index-DbtaU-EE.js +1 -0
- data/public/railwatch/assets/index-DeOe83F4.js +1 -0
- data/public/railwatch/assets/index-DiucHN4B.js +1 -0
- data/public/railwatch/assets/index-DlnR_l9o.js +1 -0
- data/public/railwatch/assets/index-DlumCsWY.js +2 -0
- data/public/railwatch/assets/index-DmRd7aIG.js +1 -0
- data/public/railwatch/assets/index-DxSh2UpM.js +1 -0
- data/public/railwatch/assets/index-MIMGuFNt.js +1 -0
- data/public/railwatch/assets/index-OqI59zPb.js +1 -0
- data/public/railwatch/assets/index-P4rC7IlX.js +1 -0
- data/public/railwatch/assets/index-gpPOcFWq.js +1 -0
- data/public/railwatch/assets/index-oVkururr.js +1 -0
- data/public/railwatch/assets/index-p9puqVge.js +1 -0
- data/public/railwatch/assets/inertia-TViv6kNv.js +97 -0
- data/public/railwatch/assets/input-error-LxImUkxv.js +1 -0
- data/public/railwatch/assets/json-viewer-Ar4cjPDW.js +1 -0
- data/public/railwatch/assets/klass-CJ-J4INB.js +1 -0
- data/public/railwatch/assets/label-COUKWqE_.js +1 -0
- data/public/railwatch/assets/layout-DNSLAkw_.js +1 -0
- data/public/railwatch/assets/live-dot-ChfUtY3p.js +41 -0
- data/public/railwatch/assets/nav-CNnDqPlm.js +1 -0
- data/public/railwatch/assets/new-84S8ZJq9.js +1 -0
- data/public/railwatch/assets/new-Be55nmt9.js +1 -0
- data/public/railwatch/assets/new-Bi_xQiIb.js +1 -0
- data/public/railwatch/assets/new-BvCT8TMg.js +1 -0
- data/public/railwatch/assets/new-D05SajFR.js +1 -0
- data/public/railwatch/assets/new-D4uewYC8.js +1 -0
- data/public/railwatch/assets/onboarding-CYZi5Cqc.js +1 -0
- data/public/railwatch/assets/origin-identity-6q1-CBts.js +1 -0
- data/public/railwatch/assets/percentile-picker-gFZCXtdb.js +1 -0
- data/public/railwatch/assets/relative-time-IOOgl5n2.js +1 -0
- data/public/railwatch/assets/release-health-DC8oc7uw.js +1 -0
- data/public/railwatch/assets/route-Dv6LAWvT.js +1 -0
- data/public/railwatch/assets/segmented-h1VdDTqE.js +1 -0
- data/public/railwatch/assets/select-_AJsUa7X.js +1 -0
- data/public/railwatch/assets/separator-BwwTYtCF.js +1 -0
- data/public/railwatch/assets/series-chart-DaFPefku.js +1 -0
- data/public/railwatch/assets/show-B7NCgkEo.js +1 -0
- data/public/railwatch/assets/show-BKqyKjBK.js +1 -0
- data/public/railwatch/assets/show-BM6X2Mpo.js +1 -0
- data/public/railwatch/assets/show-BNw4tN5q.js +1 -0
- data/public/railwatch/assets/show-BO3bnG5h.js +1 -0
- data/public/railwatch/assets/show-BhrAVAEA.js +1 -0
- data/public/railwatch/assets/show-C4Ltf5i9.js +2 -0
- data/public/railwatch/assets/show-C8sHalnw.js +1 -0
- data/public/railwatch/assets/show-CeTL4B37.js +2 -0
- data/public/railwatch/assets/show-CpfgV1jP.js +1 -0
- data/public/railwatch/assets/show-DACku6AD.js +3 -0
- data/public/railwatch/assets/show-DIOSGcXV.js +6 -0
- data/public/railwatch/assets/show-DQp_1n-B.js +1 -0
- data/public/railwatch/assets/show-DVNz46RI.js +1 -0
- data/public/railwatch/assets/show-DYteoYWW.js +1 -0
- data/public/railwatch/assets/show-DgSIoRvA.js +1 -0
- data/public/railwatch/assets/show-JxFtB4eK.js +2 -0
- data/public/railwatch/assets/sort-header-DpFzXblu.js +1 -0
- data/public/railwatch/assets/source-link-B2183i2-.js +1 -0
- data/public/railwatch/assets/sparkline-cell-C3-5vFkP.js +1 -0
- data/public/railwatch/assets/stat-s4RpOS9w.js +1 -0
- data/public/railwatch/assets/status-badge-8jVV-LA4.js +1 -0
- data/public/railwatch/assets/tenant-path-G-6u9A-o.js +1 -0
- data/public/railwatch/assets/text-link-DfsiaCcP.js +1 -0
- data/public/railwatch/assets/textarea-Dye72uP7.js +1 -0
- data/public/railwatch/assets/timeline-CD7WHnbo.js +1 -0
- data/public/railwatch/assets/transition-B_AW8rMK.js +5 -0
- data/public/railwatch/assets/use-clipboard-ColgLyQ2.js +1 -0
- data/public/railwatch/icon.png +0 -0
- data/public/railwatch/icon.svg +5 -0
- data/public/railwatch/manifest.json +2171 -0
- data/public/railwatch/rails-vite.json +1 -0
- metadata +314 -4
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Railwatch
|
|
4
|
+
class Deploy < ApplicationRecord
|
|
5
|
+
self.table_name = "railwatch_deploys"
|
|
6
|
+
def environment = Environment.current
|
|
7
|
+
|
|
8
|
+
validates :deploy, presence: true, uniqueness: { scope: :environment_id }
|
|
9
|
+
validates :deployed_at, presence: true
|
|
10
|
+
# Rendered as the "Open" link on the deploy page. Same rule as
|
|
11
|
+
# Application#repository_url, checked by parsing: one whole http(s) URL
|
|
12
|
+
# with a host, so a hook (or whoever holds its token) cannot put a
|
|
13
|
+
# javascript:/data: link, a hostless "https://?x", or an arbitrary
|
|
14
|
+
# off-site destination behind a trusted button.
|
|
15
|
+
validate :url_is_a_web_address
|
|
16
|
+
|
|
17
|
+
scope :recent, -> { order(deployed_at: :desc) }
|
|
18
|
+
scope :between, ->(from, to) { where(deployed_at: from..to) }
|
|
19
|
+
|
|
20
|
+
before_validation :assign_previous_ref
|
|
21
|
+
before_validation :trim_commits
|
|
22
|
+
|
|
23
|
+
def short_ref
|
|
24
|
+
ref.to_s.first(7).presence || deploy.first(12)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def commits_count
|
|
28
|
+
commits.size
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# The deploy immediately before this one in the same environment.
|
|
32
|
+
def previous_deploy
|
|
33
|
+
environment.deploys.where(deployed_at: ...deployed_at).recent.first
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# The stretch of time this release was live: from its own deploy until the
|
|
37
|
+
# next one, or until now for the current release. Release health is summed
|
|
38
|
+
# over this, not over the page's window.
|
|
39
|
+
def window
|
|
40
|
+
[ deployed_at, environment.deploys.where("deployed_at > ?", deployed_at).order(:deployed_at).first&.deployed_at || Time.current ]
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def url_is_a_web_address
|
|
46
|
+
return if url.blank?
|
|
47
|
+
parsed = url.match?(/\A\S+\z/) ? URI.parse(url) : nil
|
|
48
|
+
return if parsed.is_a?(URI::HTTP) && parsed.host.present?
|
|
49
|
+
errors.add(:url, "must be a single http:// or https:// URL")
|
|
50
|
+
rescue URI::InvalidURIError
|
|
51
|
+
errors.add(:url, "must be a single http:// or https:// URL")
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def assign_previous_ref
|
|
55
|
+
return if previous_ref.present? || deployed_at.blank?
|
|
56
|
+
self.previous_ref = previous_deploy&.ref
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# The post-deploy hook sends its most recent commits (newest first) without
|
|
60
|
+
# knowing where the last deploy stopped, so cut the list at that commit.
|
|
61
|
+
def trim_commits
|
|
62
|
+
return if previous_ref.blank?
|
|
63
|
+
index = commits.index { |c| c["sha"].to_s.start_with?(previous_ref) }
|
|
64
|
+
self.commits = commits.first(index) if index
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Railwatch
|
|
4
|
+
# The one environment an embedded install monitors: the host application in
|
|
5
|
+
# its current Rails.env. Not a table. The hosted platform's Environment is an
|
|
6
|
+
# account-scoped Active Record row with a token, quota and pause state; the
|
|
7
|
+
# telemetry, ingest and dashboard code only ever asks it for an id, a slug,
|
|
8
|
+
# a name, with_telemetry, and a few display attributes, so this stands in
|
|
9
|
+
# with exactly that surface.
|
|
10
|
+
class Environment
|
|
11
|
+
ID = 1
|
|
12
|
+
|
|
13
|
+
attr_reader :id, :name, :slug, :application_name
|
|
14
|
+
|
|
15
|
+
def self.current = @current ||= new
|
|
16
|
+
|
|
17
|
+
def self.find(id)
|
|
18
|
+
raise ActiveRecord::RecordNotFound, "Environment #{id}" unless id.to_i == ID
|
|
19
|
+
current
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def initialize
|
|
23
|
+
@id = ID
|
|
24
|
+
@name = Rails.env.to_s
|
|
25
|
+
@application_name = Rails.application.class.module_parent_name
|
|
26
|
+
@slug = "#{@application_name.parameterize}-#{@name}"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def with_telemetry(&block) = TelemetryRecord.with_tenant(slug, &block)
|
|
30
|
+
def telemetry_exists? = true
|
|
31
|
+
def paused? = false
|
|
32
|
+
def token_prefix = "embedded"
|
|
33
|
+
def retention_days = 7
|
|
34
|
+
def last_seen_at = Railwatch::Embedded.last_seen_at
|
|
35
|
+
def application = Application.current
|
|
36
|
+
def issues = Issue.where(environment_id: ID)
|
|
37
|
+
def deploys = Deploy.where(environment_id: ID)
|
|
38
|
+
def saved_views = SavedView.where(environment_id: ID)
|
|
39
|
+
def thresholds = Threshold.where(environment_id: ID)
|
|
40
|
+
def anomaly_rules = AnomalyRule.where(environment_id: ID)
|
|
41
|
+
def events_this_month = 0
|
|
42
|
+
# The platform lets an account list the servers it expects to report so the
|
|
43
|
+
# dashboard can flag silent ones. One embedded install is its own server.
|
|
44
|
+
def expected_servers = []
|
|
45
|
+
|
|
46
|
+
# Ingest::Batch touches this in embedded mode; there is no row to update.
|
|
47
|
+
def update_columns(last_seen_at:) = Railwatch::Embedded.last_seen_at = last_seen_at
|
|
48
|
+
|
|
49
|
+
# GlobalID for Active Job arguments (RollupJob.perform_later(environment, bucket)).
|
|
50
|
+
include GlobalID::Identification
|
|
51
|
+
def to_global_id(...) = GlobalID.create(self, app: "railwatch")
|
|
52
|
+
def self.find_by_id(id) = find(id)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Railwatch
|
|
4
|
+
# Everything the execution detail page needs: the parent, its waterfall
|
|
5
|
+
# timeline of children, exceptions with frames, logs, and links to the
|
|
6
|
+
# enqueuing request or the jobs it enqueued.
|
|
7
|
+
class ExecutionPresenter
|
|
8
|
+
# The profile page renders up to 2 MiB of collapsed stacks; the execution
|
|
9
|
+
# page only carries an inline preview flamegraph alongside everything else
|
|
10
|
+
# it already ships, so it takes a smaller slice and links out when cut.
|
|
11
|
+
INLINE_COLLAPSED_BYTES = 512.kilobytes
|
|
12
|
+
|
|
13
|
+
# Everything an attachment row needs except `data`, which is the whole
|
|
14
|
+
# gzipped file and never belongs in a page prop.
|
|
15
|
+
ATTACHMENT_COLUMNS = %i[id name content_type bytes truncated occurred_at execution_stage].freeze
|
|
16
|
+
|
|
17
|
+
def initialize(execution, environment)
|
|
18
|
+
@exe = execution
|
|
19
|
+
@environment = environment
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def props
|
|
23
|
+
@environment.with_telemetry do
|
|
24
|
+
{
|
|
25
|
+
execution: base,
|
|
26
|
+
timeline: timeline,
|
|
27
|
+
exceptions: @exe.exceptions.map { |e| exception_row(e) },
|
|
28
|
+
logs: @exe.logs.order(:occurred_at).limit(500).map { |l| { id: l.id, level: l.level, message: l.message, tags: l.tags, occurred_at: l.occurred_at, stage: l.execution_stage } },
|
|
29
|
+
queries: @exe.queries.order(:occurred_at).limit(500).with_sql.map { |q| { id: q.id, sql: q.sql, duration: q.duration_ms.round(3), source: q.source, group_hash: q.group_hash, row_count: q.row_count, stage: q.execution_stage, offset: offset(q) } },
|
|
30
|
+
spans: spans,
|
|
31
|
+
profile: profile_card,
|
|
32
|
+
attachments: attachment_rows.map { |a| attachment_row(a) },
|
|
33
|
+
enqueued_jobs: enqueued_jobs,
|
|
34
|
+
parent: parent,
|
|
35
|
+
trace: trace,
|
|
36
|
+
trace_url: trace_url,
|
|
37
|
+
issues: issues,
|
|
38
|
+
person: person
|
|
39
|
+
}
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Public so the issue page can build breadcrumbs from an occurrence's
|
|
44
|
+
# execution without re-implementing the per-type mapping.
|
|
45
|
+
def timeline_entries
|
|
46
|
+
timeline
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def base
|
|
52
|
+
{
|
|
53
|
+
execution_id: @exe.execution_id, kind: @exe.kind, name: @exe.name, duration: @exe.duration_ms.round(3), status: @exe.status,
|
|
54
|
+
outcome: @exe.outcome, method: @exe.method, route: @exe.route, controller: @exe.controller, action: @exe.action,
|
|
55
|
+
queue: @exe.queue, attempt: @exe.attempt, queue_latency: @exe.queue_latency && (@exe.queue_latency / 1000.0).round(1),
|
|
56
|
+
queue_time: @exe.queue_time && (@exe.queue_time / 1000.0).round(1),
|
|
57
|
+
job_id: @exe.job_id, task_key: @exe.task_key, inertia_component: @exe.inertia_component,
|
|
58
|
+
occurred_at: @exe.occurred_at, deploy: @exe.deploy, server: @exe.server, user_ref: @exe.user_ref, tenant: @exe.app_tenant,
|
|
59
|
+
trace_id: @exe.trace_id, allocations: @exe.allocations, peak_memory: @exe.peak_memory, exception_preview: @exe.exception_preview,
|
|
60
|
+
profiled: profile.present?,
|
|
61
|
+
stages: @exe.stages.transform_values { |v| (v / 1000.0).round(3) }, counters: @exe.counters, detail: @exe.detail
|
|
62
|
+
}
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def offset(row)
|
|
66
|
+
((row.occurred_at - @exe.occurred_at) * 1000.0).round(3)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Rich waterfall entries for the Timeline component: unlike the model's
|
|
70
|
+
# generic `timeline_label`, this keeps type-specific fields (sql, status,
|
|
71
|
+
# subject, level...) so the frontend can render a real hover/detail panel
|
|
72
|
+
# instead of a truncated string.
|
|
73
|
+
def timeline
|
|
74
|
+
entries = []
|
|
75
|
+
@exe.queries.with_sql.each { |q| entries << { type: "query", id: q.id, offset: offset(q), duration: q.duration_ms, stage: q.execution_stage,
|
|
76
|
+
label: q.sql.to_s.first(120), sql: q.sql, source: q.source, detail: { "rows" => q.row_count, "connection" => q.connection } } }
|
|
77
|
+
@exe.cache_events.each { |c| entries << { type: "cache_event", id: c.id, offset: offset(c), duration: c.duration_ms, stage: c.execution_stage,
|
|
78
|
+
label: "#{c.type} #{c.key}".first(120), source: nil, detail: { "key" => c.key, "op" => c.type, "store" => c.store, "hits" => c.hits, "ttl" => c.ttl } } }
|
|
79
|
+
@exe.outgoing_requests.each { |o| entries << { type: "outgoing_request", id: o.id, offset: offset(o), duration: o.duration_ms, stage: o.execution_stage,
|
|
80
|
+
label: o.url.to_s.first(120), source: o.source, detail: { "method" => o.method, "url" => o.url, "status" => o.status_code, "host" => o.host, "error" => o.error, "body" => o.response_body } } }
|
|
81
|
+
@exe.mails.each { |m| entries << { type: "mail", id: m.id, offset: offset(m), duration: m.duration_ms, stage: m.execution_stage,
|
|
82
|
+
label: m.mailer.to_s.first(120), source: nil, detail: { "subject" => m.subject, "mailer" => m.mailer, "to" => m.to } } }
|
|
83
|
+
@exe.logs.each { |l| entries << { type: "log", id: l.id, offset: offset(l), duration: l.duration_ms, stage: l.execution_stage,
|
|
84
|
+
label: l.message.to_s.first(120), source: l.source, detail: { "level" => l.level, "message" => l.message.to_s.first(500) } } }
|
|
85
|
+
@exe.exceptions.each { |e| entries << { type: "exception", id: e.id, offset: offset(e), duration: e.duration_ms, stage: e.execution_stage,
|
|
86
|
+
label: "#{e.class_name}: #{e.message.to_s.first(100)}", source: (e.file ? "#{e.file}:#{e.line}" : nil), detail: { "class" => e.class_name, "message" => e.message.to_s.first(300), "handled" => e.handled } } }
|
|
87
|
+
@exe.broadcasts.each { |b| entries << { type: "broadcast", id: b.id, offset: offset(b), duration: b.duration_ms, stage: b.execution_stage,
|
|
88
|
+
label: b.channel.to_s.first(120), source: nil, detail: { "channel" => b.channel, "action" => b.action, "stream" => b.stream } } }
|
|
89
|
+
@exe.storage_ops.each { |s| entries << { type: "storage_op", id: s.id, offset: offset(s), duration: s.duration_ms, stage: s.execution_stage,
|
|
90
|
+
label: "#{s.op} #{s.key}".first(120), source: nil, detail: { "op" => s.op, "key" => s.key, "service" => s.service } } }
|
|
91
|
+
@exe.view_renders.each { |v| entries << { type: "view_render", id: v.id, offset: offset(v), duration: v.duration_ms, stage: v.execution_stage,
|
|
92
|
+
label: v.identifier.to_s.first(120), source: nil, detail: { "count" => v.count, "cache_hits" => v.cache_hits } } }
|
|
93
|
+
@exe.enqueued_jobs.each { |j| entries << { type: "enqueued_job", id: j.id, offset: offset(j), duration: j.duration_ms, stage: j.execution_stage,
|
|
94
|
+
label: j.name.to_s.first(120), source: nil, detail: { "queue" => j.queue, "job_id" => j.job_id } } }
|
|
95
|
+
@exe.spans.each { |s| entries << { type: "span", id: s.id, offset: offset(s), duration: s.duration_ms, stage: s.execution_stage,
|
|
96
|
+
label: s.name.to_s.first(120), source: nil, detail: { "status" => s.status }.merge(s.payload) } }
|
|
97
|
+
@exe.transactions.each { |t| entries << { type: "transaction", id: t.id, offset: offset(t), duration: t.duration_ms, stage: t.execution_stage,
|
|
98
|
+
label: t.outcome.to_s.first(120), source: nil, detail: { "outcome" => t.outcome, "connection" => t.connection } } }
|
|
99
|
+
@exe.llm_calls.each { |c| entries << { type: "llm_call", id: c.id, offset: offset(c), duration: c.duration_ms, stage: c.execution_stage,
|
|
100
|
+
label: c.timeline_label.first(120), source: nil, detail: { "operation" => c.operation, "provider" => c.provider, "model" => c.model,
|
|
101
|
+
"tool" => c.tool_name, "input_tokens" => c.input_tokens, "output_tokens" => c.output_tokens, "cost" => c.cost,
|
|
102
|
+
"status" => c.status, "error" => c.error, "workflow" => c.workflow_name, "step" => c.workflow_step_name } } }
|
|
103
|
+
attachment_rows.each { |a| entries << { type: "attachment", id: a.id, offset: offset(a), duration: nil, stage: a.execution_stage,
|
|
104
|
+
label: a.name.to_s.first(120), source: nil, detail: { "name" => a.name, "content_type" => a.content_type, "bytes" => a.bytes, "truncated" => a.truncated } } }
|
|
105
|
+
entries.each { |e| e[:duration] = e[:duration]&.round(3) }
|
|
106
|
+
entries.sort_by { |e| e[:offset] }
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def exception_row(e)
|
|
110
|
+
{ id: e.id, class_name: e.class_name, message: e.message, handled: e.handled, severity: e.severity, source: e.source,
|
|
111
|
+
file: e.file, line: e.line, frames: e.frames, cause: e.cause, locals: e.locals, context: e.context, occurred_at: e.occurred_at, group_hash: e.group_hash }
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def spans
|
|
115
|
+
@exe.spans.order(:occurred_at).limit(200).map do |s|
|
|
116
|
+
{ id: s.id, name: s.name, duration: s.duration_ms&.round(3), status: s.status, attributes: s.payload,
|
|
117
|
+
offset: offset(s), stage: s.execution_stage, occurred_at: s.occurred_at }
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# The execution's profile, if one was shipped. Looked up by execution_id
|
|
122
|
+
# rather than through the denormalised executions.profile_id so a profile
|
|
123
|
+
# that arrived in a later batch than its execution still shows up.
|
|
124
|
+
def profile
|
|
125
|
+
return @profile if defined?(@profile)
|
|
126
|
+
@profile = @exe.profiles.recent.first
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def profile_card
|
|
130
|
+
row = profile or return nil
|
|
131
|
+
collapsed, truncated = row.collapsed_capped(INLINE_COLLAPSED_BYTES)
|
|
132
|
+
{ id: row.id, profiler: row.profiler, mode: row.mode, interval: row.interval, duration: row.duration_ms.round(2),
|
|
133
|
+
samples: row.samples, stacks_bytes: row.stacks_bytes, collapsed: collapsed, truncated: truncated }
|
|
134
|
+
rescue Telemetry::BoundedGzip::Error
|
|
135
|
+
nil
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def attachment_rows
|
|
139
|
+
@attachment_rows ||= @exe.attachments.order(:occurred_at).limit(50).select(*ATTACHMENT_COLUMNS).to_a
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def attachment_row(a)
|
|
143
|
+
{ id: a.id, name: a.name, content_type: a.content_type, bytes: a.bytes, truncated: a.truncated,
|
|
144
|
+
viewable: a.viewable?, occurred_at: a.occurred_at }
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def enqueued_jobs
|
|
148
|
+
@exe.enqueued_jobs.map do |j|
|
|
149
|
+
attempt = Telemetry::Execution.jobs.find_by(job_id: j.job_id)
|
|
150
|
+
{ id: j.id, name: j.name, queue: j.queue, job_id: j.job_id, scheduled_at: j.scheduled_at, offset: offset(j),
|
|
151
|
+
attempt_execution_id: attempt&.execution_id, attempt_outcome: attempt&.outcome }
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def parent
|
|
156
|
+
return nil unless @exe.kind == "job_attempt" && @exe.trace_id
|
|
157
|
+
p = Telemetry::Execution.where(trace_id: @exe.trace_id).where.not(execution_id: @exe.execution_id).order(:occurred_at).first
|
|
158
|
+
p && { execution_id: p.execution_id, kind: p.kind, name: p.name }
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def trace
|
|
162
|
+
@trace ||= Telemetry::Execution.where(trace_id: @exe.trace_id).order(:occurred_at).limit(50)
|
|
163
|
+
.map { |e| { execution_id: e.execution_id, kind: e.kind, name: e.name, duration: e.duration_ms.round(1), status: e.status, outcome: e.outcome,
|
|
164
|
+
occurred_at: e.occurred_at, parent_id: e.parent_id, server: e.server, current: e.execution_id == @exe.execution_id } }
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Only worth linking out to the distributed trace page when this execution
|
|
168
|
+
# is not the whole trace.
|
|
169
|
+
def trace_url
|
|
170
|
+
return nil unless @exe.trace_id && trace.size > 1
|
|
171
|
+
Railwatch::Engine.routes.url_helpers.application_environment_trace_path(@environment.application, @environment, @exe.trace_id)
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def issues
|
|
175
|
+
groups = @exe.exceptions.map(&:group_hash).uniq
|
|
176
|
+
@environment.issues.where(group_hash: groups).map { |i| { id: i.id, key: i.key, title: i.title, status: i.status, group_hash: i.group_hash } }
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def person
|
|
180
|
+
return nil unless @exe.user_ref
|
|
181
|
+
p = Telemetry::Person.find_by(ref: @exe.user_ref) or return nil
|
|
182
|
+
{ ref: p.ref, name: p.display_name, email: p.email }
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
end
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Railwatch
|
|
4
|
+
# Parses and applies the "key:value key2:value2 free text" grammar shared by
|
|
5
|
+
# telemetry pages, REST endpoints, and MCP tools.
|
|
6
|
+
module FilterQuery
|
|
7
|
+
TOKEN = /\A([a-z_]+):(\S+)\z/
|
|
8
|
+
EXECUTION_KINDS = %w[request job scheduled_task command].freeze
|
|
9
|
+
LOG_LEVELS = %w[debug info warn error fatal event unknown].freeze
|
|
10
|
+
HANDLED_VALUES = %w[true false].freeze
|
|
11
|
+
|
|
12
|
+
COMMON_FIELDS = %w[after before user tenant deploy kind].freeze
|
|
13
|
+
RESOURCE_FIELDS = {
|
|
14
|
+
jobs: %w[class job_id outcome queue status],
|
|
15
|
+
exceptions: %w[source class handled severity status],
|
|
16
|
+
logs: %w[source level status],
|
|
17
|
+
queries: %w[source adapter connection role]
|
|
18
|
+
}.freeze
|
|
19
|
+
|
|
20
|
+
def self.parse(query)
|
|
21
|
+
text = []
|
|
22
|
+
fields = {}
|
|
23
|
+
query.to_s.split(/\s+/).each do |word|
|
|
24
|
+
if (m = TOKEN.match(word))
|
|
25
|
+
fields[m[1]] = m[2]
|
|
26
|
+
elsif word.present?
|
|
27
|
+
text << word
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
{ text: text.join(" "), fields: fields }
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# "5xx" -> 500..599, "404" -> 404..404, anything else -> nil
|
|
34
|
+
def self.status_range(value)
|
|
35
|
+
case value.to_s
|
|
36
|
+
when /\A([2-5])xx\z/i then ($1.to_i * 100)..($1.to_i * 100 + 99)
|
|
37
|
+
when /\A\d+\z/ then value.to_i..value.to_i
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def self.fields_for(resource)
|
|
42
|
+
COMMON_FIELDS + RESOURCE_FIELDS.fetch(resource.to_sym)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def self.cursor_context(environment_id:, resource:, query:, window:)
|
|
46
|
+
Digest::SHA256.hexdigest([ environment_id, resource, query.to_s, window ].to_json)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Applies the same telemetry grammar for the web UI, REST API, and MCP.
|
|
50
|
+
# The supplied window remains the outer bound; after:/before: may only
|
|
51
|
+
# narrow it. Unknown fields are inert, and every value is bound through
|
|
52
|
+
# Active Record rather than interpolated into SQL.
|
|
53
|
+
def self.apply(scope, resource:, query:, from:, to:, except: [])
|
|
54
|
+
resource = resource.to_sym
|
|
55
|
+
parsed = parse(query)
|
|
56
|
+
fields = parsed[:fields].except(*Array(except).map(&:to_s))
|
|
57
|
+
scope = scope.where(occurred_at: bounded_time_range(fields, from, to))
|
|
58
|
+
scope = apply_common(scope, fields)
|
|
59
|
+
|
|
60
|
+
case resource
|
|
61
|
+
when :jobs then apply_jobs(scope, fields, parsed[:text])
|
|
62
|
+
when :exceptions then apply_exceptions(scope, fields, parsed[:text])
|
|
63
|
+
when :logs then apply_logs(scope, fields, parsed[:text])
|
|
64
|
+
when :queries then apply_queries(scope, fields, parsed[:text])
|
|
65
|
+
else raise ArgumentError, "unsupported telemetry resource: #{resource}"
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def self.bounded_time_range(fields, from, to)
|
|
70
|
+
after = parse_time(fields["after"])
|
|
71
|
+
before = parse_time(fields["before"])
|
|
72
|
+
lower = [ from, after ].compact.max
|
|
73
|
+
upper = [ to, before ].compact.min
|
|
74
|
+
lower <= upper ? lower..upper : (lower...lower)
|
|
75
|
+
end
|
|
76
|
+
private_class_method :bounded_time_range
|
|
77
|
+
|
|
78
|
+
def self.parse_time(value)
|
|
79
|
+
return if value.blank?
|
|
80
|
+
Time.zone.parse(value.to_s)
|
|
81
|
+
rescue ArgumentError, TypeError
|
|
82
|
+
nil
|
|
83
|
+
end
|
|
84
|
+
private_class_method :parse_time
|
|
85
|
+
|
|
86
|
+
def self.apply_common(scope, fields)
|
|
87
|
+
scope = scope.where(user_ref: fields["user"]) if fields["user"].present?
|
|
88
|
+
scope = scope.where(app_tenant: fields["tenant"]) if fields["tenant"].present?
|
|
89
|
+
scope = scope.where(deploy: fields["deploy"]) if fields["deploy"].present?
|
|
90
|
+
if fields["source"].present?
|
|
91
|
+
if EXECUTION_KINDS.include?(fields["source"].to_s) && scope.klass.column_names.include?("execution_source")
|
|
92
|
+
scope = scope.where(execution_source: fields["source"])
|
|
93
|
+
elsif scope.klass.column_names.include?("source")
|
|
94
|
+
pattern = "#{scope.klass.sanitize_sql_like(fields["source"])}%"
|
|
95
|
+
scope = scope.where(scope.klass.arel_table[:source].matches(pattern))
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
scope = scope.where(execution_source: fields["kind"]) if EXECUTION_KINDS.include?(fields["kind"].to_s) && scope.klass.column_names.include?("execution_source")
|
|
99
|
+
scope
|
|
100
|
+
end
|
|
101
|
+
private_class_method :apply_common
|
|
102
|
+
|
|
103
|
+
def self.apply_jobs(scope, fields, text)
|
|
104
|
+
outcome = fields["outcome"].presence || fields["status"].presence
|
|
105
|
+
scope = scope.where(queue: fields["queue"]) if fields["queue"].present?
|
|
106
|
+
scope = scope.where(outcome: outcome) if outcome
|
|
107
|
+
scope = scope.where(name: fields["class"]) if fields["class"].present?
|
|
108
|
+
scope = scope.where(job_id: fields["job_id"]) if fields["job_id"].present?
|
|
109
|
+
return scope unless text.present?
|
|
110
|
+
pattern = "%#{Telemetry::Execution.sanitize_sql_like(text)}%"
|
|
111
|
+
scope.where("name LIKE :pattern OR exception_preview LIKE :pattern", pattern: pattern)
|
|
112
|
+
end
|
|
113
|
+
private_class_method :apply_jobs
|
|
114
|
+
|
|
115
|
+
def self.apply_exceptions(scope, fields, text)
|
|
116
|
+
handled = fields["handled"]
|
|
117
|
+
handled ||= { "handled" => "true", "unhandled" => "false" }[fields["status"]]
|
|
118
|
+
scope = scope.where(class_name: fields["class"]) if fields["class"].present?
|
|
119
|
+
scope = scope.where(handled: handled == "true") if HANDLED_VALUES.include?(handled.to_s)
|
|
120
|
+
scope = scope.where(severity: fields["severity"]) if fields["severity"].present?
|
|
121
|
+
return scope unless text.present?
|
|
122
|
+
pattern = "%#{Telemetry::Exception.sanitize_sql_like(text)}%"
|
|
123
|
+
scope.where("message LIKE :pattern OR class_name LIKE :pattern", pattern: pattern)
|
|
124
|
+
end
|
|
125
|
+
private_class_method :apply_exceptions
|
|
126
|
+
|
|
127
|
+
def self.apply_logs(scope, fields, text)
|
|
128
|
+
level = fields["level"].presence || fields["status"].presence
|
|
129
|
+
scope = scope.where(level: level) if LOG_LEVELS.include?(level.to_s)
|
|
130
|
+
text.present? ? scope.fts(text) : scope
|
|
131
|
+
end
|
|
132
|
+
private_class_method :apply_logs
|
|
133
|
+
|
|
134
|
+
def self.apply_queries(scope, fields, text)
|
|
135
|
+
%w[adapter connection role].each do |field|
|
|
136
|
+
scope = scope.where(field => fields[field]) if fields[field].present?
|
|
137
|
+
end
|
|
138
|
+
return scope unless text.present?
|
|
139
|
+
scope.matching(text)
|
|
140
|
+
end
|
|
141
|
+
private_class_method :apply_queries
|
|
142
|
+
end
|
|
143
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Railwatch
|
|
4
|
+
# One row per (batch, exception group) whose occurrences have been counted
|
|
5
|
+
# onto an issue. Lives beside the issues, in the same database and the same
|
|
6
|
+
# transaction as the count, so "did this batch's exceptions already reach
|
|
7
|
+
# this issue" has exactly one answer however many times the follow-up runs.
|
|
8
|
+
class FollowupReceipt < ApplicationRecord
|
|
9
|
+
self.table_name = "railwatch_followup_receipts"
|
|
10
|
+
|
|
11
|
+
# Receipts outlive their batch's ledger row by this much; a replay after
|
|
12
|
+
# that is one the ledger no longer knows about either.
|
|
13
|
+
RETENTION = 30.days
|
|
14
|
+
|
|
15
|
+
# True when this call is the first for the pair: the caller's work is
|
|
16
|
+
# owed. False when a receipt already exists: the work was done, skip it.
|
|
17
|
+
# Must run inside a transaction on this database with the work itself.
|
|
18
|
+
def self.claim!(batch_id:, group_hash:)
|
|
19
|
+
insert({ batch_id: batch_id, group_hash: group_hash, created_at: Time.current },
|
|
20
|
+
unique_by: %i[batch_id group_hash]).rows.any?
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def self.prune!(now: Time.current)
|
|
24
|
+
where(created_at: ...(now - RETENTION)).delete_all
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|