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,130 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Railwatch
|
|
4
|
+
# Recomputes the hourly rollups for one bucket from raw rows. Idempotent:
|
|
5
|
+
# runs again whenever new rows land in that hour. Debounced twice: Ingest::
|
|
6
|
+
# Batch enqueues at most one run per bucket per minute, and Solid Queue's
|
|
7
|
+
# concurrency control discards an enqueue while a run for that bucket is
|
|
8
|
+
# already queued or running (the default :block would let a busy hour pile
|
|
9
|
+
# up thousands of identical jobs). RollupCatchupJob sweeps the current and
|
|
10
|
+
# previous hour on its recurring schedule for anything either debounce
|
|
11
|
+
# dropped; an embedded install instead runs this once an hour for the
|
|
12
|
+
# previous hour from Railwatch::Maintenance, since its batches already
|
|
13
|
+
# fold themselves into the current hour as they land.
|
|
14
|
+
class RollupJob < ApplicationJob
|
|
15
|
+
queue_as :rollups
|
|
16
|
+
limits_concurrency to: 1, key: ->(environment, bucket) { "#{environment.id}:#{bucket.to_i}" }, duration: 10.minutes, on_conflict: :discard
|
|
17
|
+
|
|
18
|
+
SOURCES = {
|
|
19
|
+
"request" => [ Telemetry::Execution, ->(r) { r.requests }, ->(row) { row.name } ],
|
|
20
|
+
"job_attempt" => [ Telemetry::Execution, ->(r) { r.jobs }, ->(row) { row.name } ],
|
|
21
|
+
"scheduled_task" => [ Telemetry::Execution, ->(r) { r.scheduled }, ->(row) { row.name } ],
|
|
22
|
+
"command" => [ Telemetry::Execution, ->(r) { r.commands }, ->(row) { row.name } ],
|
|
23
|
+
"channel_action" => [ Telemetry::Execution, ->(r) { r.channels }, ->(row) { row.name } ],
|
|
24
|
+
"query" => [ Telemetry::Query, ->(r) { r }, ->(row) { row.sql.first(255) } ],
|
|
25
|
+
"outgoing_request" => [ Telemetry::OutgoingRequest, ->(r) { r }, ->(row) { "#{row.method} #{row.host}" } ],
|
|
26
|
+
"cache_event" => [ Telemetry::CacheEvent, ->(r) { r }, ->(row) { "#{row.store} #{row.key}" } ],
|
|
27
|
+
"mail" => [ Telemetry::Mail, ->(r) { r }, ->(row) { row.mailer } ],
|
|
28
|
+
"visit" => [ Telemetry::Visit, ->(r) { r }, ->(row) { row.component } ],
|
|
29
|
+
"span" => [ Telemetry::Span, ->(r) { r }, ->(row) { row.name } ],
|
|
30
|
+
"notification" => [ Telemetry::Notification, ->(r) { r }, ->(row) { row.notifier || row.delivery_method } ],
|
|
31
|
+
"view_render" => [ Telemetry::ViewRender, ->(r) { r }, ->(row) { row.identifier } ],
|
|
32
|
+
"transaction" => [ Telemetry::Transaction, ->(r) { r }, ->(row) { "#{row.connection} · #{row.outcome}" } ],
|
|
33
|
+
"llm_call" => [ Telemetry::LlmCall, ->(r) { r.models }, ->(row) { "#{row.model} · #{row.operation}" } ],
|
|
34
|
+
"llm_tool" => [ Telemetry::LlmCall, ->(r) { r.tools }, ->(row) { row.tool_name.to_s } ]
|
|
35
|
+
}.freeze
|
|
36
|
+
|
|
37
|
+
# SQLite binds at most 32,766 variables per statement; a rollup row is 14.
|
|
38
|
+
INSERT_SLICE = 500
|
|
39
|
+
|
|
40
|
+
def perform(environment, bucket)
|
|
41
|
+
bucket = bucket.utc.beginning_of_hour
|
|
42
|
+
range = bucket...(bucket + 1.hour)
|
|
43
|
+
# Recomputing a busy hour is thousands of queries; recording each of
|
|
44
|
+
# them into Railwatch's own telemetry was 70% of everything this platform
|
|
45
|
+
# ingested about itself. The job record still ships with its duration.
|
|
46
|
+
environment.with_telemetry do
|
|
47
|
+
Railwatch.ignore { recompute(environment, bucket, range) }
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
# Every group of one type is written in one transaction: the hour's
|
|
54
|
+
# rollups for that type replace each other atomically, and the write lock
|
|
55
|
+
# is held only for the writes. Rails opens each SQLite transaction
|
|
56
|
+
# IMMEDIATE, so reading the hour inside it would hold every other writer
|
|
57
|
+
# off the tenant file for the whole read -- a busy hour is tens of
|
|
58
|
+
# seconds, and ingest gets five before it gives up. On 2026-09-05 one
|
|
59
|
+
# 37-second recompute failed every batch that arrived while it ran.
|
|
60
|
+
def recompute(environment, bucket, range)
|
|
61
|
+
SOURCES.each do |type, (klass, scope, namer)|
|
|
62
|
+
Railwatch.span("rollup.#{type}", bucket: bucket.iso8601) do
|
|
63
|
+
rows = scope.call(klass).where(occurred_at: range).select(:id, :group_hash, :duration, *(klass == Telemetry::Execution ? %i[name status outcome kind] : %i[]), *(klass == Telemetry::Query ? [ Telemetry::Query::SQL ] : []), *(klass == Telemetry::OutgoingRequest ? %i[method host status_code] : []), *(klass == Telemetry::CacheEvent ? %i[store key type] : []), *(klass == Telemetry::Mail ? %i[mailer failed] : []), *(klass == Telemetry::Visit ? %i[component status] : []), *(klass == Telemetry::Notification ? %i[notifier delivery_method failed] : []), *(klass == Telemetry::ViewRender ? %i[identifier kind] : []), *(klass == Telemetry::Transaction ? %i[outcome connection] : []), *(klass == Telemetry::Span ? %i[name status] : []), *(klass == Telemetry::LlmCall ? %i[operation model tool_name status input_tokens output_tokens cache_read_tokens cache_write_tokens cost_nanos finish_reason attachments] : []))
|
|
64
|
+
groups = rows.group_by(&:group_hash).except(nil)
|
|
65
|
+
next if groups.empty?
|
|
66
|
+
rows = groups.map do |group_hash, group|
|
|
67
|
+
Telemetry::Rollup.fresh_attributes(
|
|
68
|
+
record_type: type, group_hash: group_hash, name: namer.call(group.first).to_s, bucket: bucket,
|
|
69
|
+
durations: group.map(&:duration),
|
|
70
|
+
errors: group.count { |r| error?(type, r) },
|
|
71
|
+
client_errors: group.count { |r| type == "request" && r.status.to_i.between?(400, 499) },
|
|
72
|
+
extra: extra_for(type, group))
|
|
73
|
+
end
|
|
74
|
+
# One DELETE and a few INSERTs per type instead of a SELECT, a DELETE
|
|
75
|
+
# and an INSERT per group: a busy hour of queries is 2,000 groups,
|
|
76
|
+
# and this job was 20,000 of the platform's own N+1 records a week.
|
|
77
|
+
Telemetry::Rollup.transaction do
|
|
78
|
+
# The hour was read outside this transaction (above), so a batch
|
|
79
|
+
# that landed in between has already been folded into the row
|
|
80
|
+
# about to be replaced (Ingest::RollupAbsorber). A stored count
|
|
81
|
+
# higher than the recomputed one means exactly that: keep the
|
|
82
|
+
# stored row rather than overwrite it with a snapshot that
|
|
83
|
+
# predates the batch. The next run picks the group up.
|
|
84
|
+
stored = Telemetry::Rollup.where(record_type: type, group_hash: groups.keys, bucket: bucket).pluck(:group_hash, :count).to_h
|
|
85
|
+
rows = rows.reject { |row| stored[row[:group_hash]].to_i > row[:count] }
|
|
86
|
+
next if rows.empty?
|
|
87
|
+
|
|
88
|
+
Telemetry::Rollup.where(record_type: type, group_hash: rows.map { |row| row[:group_hash] }, bucket: bucket).delete_all
|
|
89
|
+
rows.each_slice(INSERT_SLICE) { |slice| Telemetry::Rollup.insert_all(slice) }
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def error?(type, row)
|
|
96
|
+
case type
|
|
97
|
+
when "request" then row.status.to_i >= 500
|
|
98
|
+
when "job_attempt", "scheduled_task", "channel_action" then row.outcome == "failed"
|
|
99
|
+
when "command" then row.status.to_i != 0
|
|
100
|
+
when "outgoing_request" then row.status_code.to_i >= 500 || row.status_code.to_i.zero?
|
|
101
|
+
when "mail" then row.failed
|
|
102
|
+
when "visit" then row.status == "error"
|
|
103
|
+
when "notification" then row.failed
|
|
104
|
+
when "transaction" then row.outcome == "rollback"
|
|
105
|
+
when "span", "llm_call", "llm_tool" then row.status == "failed"
|
|
106
|
+
else false
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def extra_for(type, group)
|
|
111
|
+
case type
|
|
112
|
+
when "cache_event"
|
|
113
|
+
{ hits: group.count { |r| r.type == "hit" }, misses: group.count { |r| r.type == "miss" || r.type == "generate" } }
|
|
114
|
+
when "view_render"
|
|
115
|
+
{ kind: group.group_by(&:kind).max_by { |_kind, rows| rows.size }&.first }
|
|
116
|
+
when "llm_call"
|
|
117
|
+
{ input_tokens: group.sum { |r| r.input_tokens.to_i }, output_tokens: group.sum { |r| r.output_tokens.to_i },
|
|
118
|
+
cache_read_tokens: group.sum { |r| r.cache_read_tokens.to_i }, cache_write_tokens: group.sum { |r| r.cache_write_tokens.to_i },
|
|
119
|
+
cost_nanos: group.sum { |r| r.cost_nanos.to_i },
|
|
120
|
+
priced: group.count { |r| r.cost_nanos },
|
|
121
|
+
unpriced: group.count { |r| r.cost_nanos.nil? },
|
|
122
|
+
# A cut-off answer is not an error and will never show in the error
|
|
123
|
+
# rate, so it needs counting on its own or it stays invisible.
|
|
124
|
+
truncated: group.count { |r| r.finish_reason == "max_tokens" },
|
|
125
|
+
with_attachments: group.count { |r| r.attachments.to_i.positive? } }
|
|
126
|
+
else {}
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Railwatch
|
|
4
|
+
# Issue.record_occurrence! replaces `sample` wholesale on every breach, but the
|
|
5
|
+
# typed detector snapshot is durable context, not the latest reading. Capturing
|
|
6
|
+
# it costs a telemetry query per detector run, so re-capture only when the
|
|
7
|
+
# issue is newly open and otherwise carry the stored snapshot forward.
|
|
8
|
+
module DetectionSnapshotting
|
|
9
|
+
private
|
|
10
|
+
|
|
11
|
+
def carry_detection(issue, outcome)
|
|
12
|
+
detection = if outcome == :new || outcome == :regressed
|
|
13
|
+
yield
|
|
14
|
+
else
|
|
15
|
+
issue.sample_previously_was&.dig("detection")
|
|
16
|
+
end
|
|
17
|
+
issue.update!(sample: issue.sample.merge("detection" => detection)) if detection
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Railwatch
|
|
4
|
+
class Alert < ApplicationRecord
|
|
5
|
+
self.table_name = "railwatch_alerts"
|
|
6
|
+
MAX_DELIVERY_ATTEMPTS = 5
|
|
7
|
+
DELIVERY_LEASE = 5.minutes
|
|
8
|
+
ENQUEUE_LEASE = 5.minutes
|
|
9
|
+
RETRY_DELAYS = [ 1.minute, 5.minutes, 15.minutes, 1.hour ].freeze
|
|
10
|
+
|
|
11
|
+
# An alert is always addressed to an integration; the rule is only why it
|
|
12
|
+
# fired, and a "Send test" delivery has no rule behind it at all.
|
|
13
|
+
# No integrations in an embedded install yet: alerts are recorded, not delivered.
|
|
14
|
+
def integration = nil
|
|
15
|
+
|
|
16
|
+
def integration=(_)
|
|
17
|
+
nil
|
|
18
|
+
end
|
|
19
|
+
belongs_to :alert_rule, optional: true
|
|
20
|
+
belongs_to :issue, optional: true
|
|
21
|
+
belongs_to :summary_alert, class_name: "Alert", optional: true
|
|
22
|
+
has_many :summarized_alerts, class_name: "Alert", foreign_key: :summary_alert_id,
|
|
23
|
+
dependent: :nullify, inverse_of: :summary_alert
|
|
24
|
+
|
|
25
|
+
# `delivering` is a leased claim. A process that disappears while holding
|
|
26
|
+
# one is recovered by ReconcileAlertDeliveriesJob after DELIVERY_LEASE.
|
|
27
|
+
STATUSES = %w[pending delivering sent failed skipped collapsed summarized].freeze
|
|
28
|
+
TERMINAL_STATUSES = %w[sent failed skipped summarized].freeze
|
|
29
|
+
|
|
30
|
+
attr_accessor :suppress_auto_enqueue
|
|
31
|
+
|
|
32
|
+
before_validation :inherit_integration, on: :create
|
|
33
|
+
before_validation :assign_delivery_metadata, on: :create
|
|
34
|
+
after_create_commit :enqueue_initial_work, unless: :suppress_auto_enqueue
|
|
35
|
+
|
|
36
|
+
# How far back reconciliation looks. An alert older than this that is
|
|
37
|
+
# still pending or collapsed predates the outbox (a row an old container
|
|
38
|
+
# wrote, or one left over from before this shipped); delivering it now
|
|
39
|
+
# would page someone about something that happened hours ago.
|
|
40
|
+
RECONCILE_WINDOW = 1.hour
|
|
41
|
+
|
|
42
|
+
scope :due_for_delivery, ->(now = Time.current) {
|
|
43
|
+
where(status: "pending").where(created_at: (now - RECONCILE_WINDOW)..)
|
|
44
|
+
.where("next_delivery_at IS NULL OR next_delivery_at <= ?", now)
|
|
45
|
+
.where("delivery_enqueued_until IS NULL OR delivery_enqueued_until <= ?", now)
|
|
46
|
+
}
|
|
47
|
+
scope :with_stale_delivery_lease, ->(now = Time.current) {
|
|
48
|
+
where(status: "delivering").where("delivery_lease_expires_at <= ?", now)
|
|
49
|
+
}
|
|
50
|
+
scope :overdue_collapsed, ->(now = Time.current) {
|
|
51
|
+
where(status: "collapsed").where(created_at: (now - RECONCILE_WINDOW)..(now - AlertRule::BURST_WINDOW))
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
# Reserve a queue hand-off. If the process dies after this write and before
|
|
55
|
+
# perform_later, reconciliation retries once this short reservation expires.
|
|
56
|
+
def enqueue_delivery!(wait_until: nil)
|
|
57
|
+
reserved_until = nil
|
|
58
|
+
target = nil
|
|
59
|
+
with_lock do
|
|
60
|
+
return false unless status == "pending"
|
|
61
|
+
|
|
62
|
+
target = [ wait_until, next_delivery_at, Time.current ].compact.max
|
|
63
|
+
return false if delivery_enqueued_until&.future?
|
|
64
|
+
|
|
65
|
+
reserved_until = target + ENQUEUE_LEASE
|
|
66
|
+
update!(delivery_enqueued_until: reserved_until)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
if target.future?
|
|
70
|
+
DeliverAlertJob.set(wait_until: target).perform_later(self)
|
|
71
|
+
else
|
|
72
|
+
DeliverAlertJob.perform_later(self)
|
|
73
|
+
end
|
|
74
|
+
true
|
|
75
|
+
rescue StandardError
|
|
76
|
+
Alert.where(id: id, status: "pending", delivery_enqueued_until: reserved_until)
|
|
77
|
+
.update_all(delivery_enqueued_until: nil, updated_at: Time.current) if reserved_until
|
|
78
|
+
raise
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Atomically turns one queued job into the only worker allowed to deliver.
|
|
82
|
+
# Duplicate jobs and jobs for terminal alerts return nil without I/O.
|
|
83
|
+
def claim_delivery!
|
|
84
|
+
with_lock do
|
|
85
|
+
return unless status == "pending"
|
|
86
|
+
return if next_delivery_at.present? && next_delivery_at.future?
|
|
87
|
+
|
|
88
|
+
token = SecureRandom.uuid
|
|
89
|
+
update!(status: "delivering", delivery_attempts: delivery_attempts + 1,
|
|
90
|
+
delivery_key: delivery_key.presence || SecureRandom.uuid,
|
|
91
|
+
delivery_lease_id: token, delivery_lease_expires_at: DELIVERY_LEASE.from_now,
|
|
92
|
+
delivery_enqueued_until: nil)
|
|
93
|
+
token
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def perform_claimed_delivery!(token)
|
|
98
|
+
if integration.enabled?
|
|
99
|
+
integration.deliver(self)
|
|
100
|
+
finish_claim!(token, "sent")
|
|
101
|
+
else
|
|
102
|
+
finish_claim!(token, "skipped")
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Persists retry intent before another job is scheduled. A crash at the
|
|
107
|
+
# following queue boundary is recovered by ReconcileAlertDeliveriesJob.
|
|
108
|
+
def fail_claim!(token, exception)
|
|
109
|
+
message = "#{exception.class}: #{exception.message}".first(1000)
|
|
110
|
+
retry_at = nil
|
|
111
|
+
with_lock do
|
|
112
|
+
return unless owns_delivery_claim?(token)
|
|
113
|
+
|
|
114
|
+
if delivery_attempts >= MAX_DELIVERY_ATTEMPTS
|
|
115
|
+
update!(status: "failed", error: message, next_delivery_at: nil,
|
|
116
|
+
delivery_lease_id: nil, delivery_lease_expires_at: nil, delivery_enqueued_until: nil)
|
|
117
|
+
else
|
|
118
|
+
retry_at = RETRY_DELAYS.fetch(delivery_attempts - 1).from_now
|
|
119
|
+
update!(status: "pending", error: message, next_delivery_at: retry_at,
|
|
120
|
+
delivery_lease_id: nil, delivery_lease_expires_at: nil, delivery_enqueued_until: nil)
|
|
121
|
+
end
|
|
122
|
+
integration.update_columns(last_error: message)
|
|
123
|
+
end
|
|
124
|
+
retry_at
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# A lease expiry is indistinguishable from a worker dying after the remote
|
|
128
|
+
# endpoint accepted the message but before `sent` committed. Retrying is
|
|
129
|
+
# required for at-least-once delivery; webhook consumers can deduplicate by
|
|
130
|
+
# delivery_key / Idempotency-Key.
|
|
131
|
+
def recover_stale_delivery!
|
|
132
|
+
retryable = false
|
|
133
|
+
with_lock do
|
|
134
|
+
return false unless status == "delivering" && delivery_lease_expires_at&.past?
|
|
135
|
+
|
|
136
|
+
message = error.presence || "Delivery lease expired before completion"
|
|
137
|
+
if delivery_attempts >= MAX_DELIVERY_ATTEMPTS
|
|
138
|
+
update!(status: "failed", error: message, next_delivery_at: nil,
|
|
139
|
+
delivery_lease_id: nil, delivery_lease_expires_at: nil, delivery_enqueued_until: nil)
|
|
140
|
+
else
|
|
141
|
+
update!(status: "pending", error: message, next_delivery_at: Time.current,
|
|
142
|
+
delivery_lease_id: nil, delivery_lease_expires_at: nil, delivery_enqueued_until: nil)
|
|
143
|
+
retryable = true
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
retryable
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Explicit operator action: terminal failures get a fresh, bounded attempt
|
|
150
|
+
# budget. Sent/skipped alerts are intentionally not replayed by this path.
|
|
151
|
+
def retry_delivery!
|
|
152
|
+
with_lock do
|
|
153
|
+
return false unless status == "failed"
|
|
154
|
+
|
|
155
|
+
update!(status: "pending", error: nil, delivery_attempts: 0,
|
|
156
|
+
next_delivery_at: Time.current, delivery_enqueued_until: nil,
|
|
157
|
+
delivery_lease_id: nil, delivery_lease_expires_at: nil)
|
|
158
|
+
end
|
|
159
|
+
enqueue_delivery!
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# Synchronous delivery is retained for "Send test", whose HTTP response
|
|
163
|
+
# must report the real destination outcome. Normal alerts use the leased job
|
|
164
|
+
# path above.
|
|
165
|
+
def deliver!
|
|
166
|
+
return self if TERMINAL_STATUSES.include?(status)
|
|
167
|
+
return update!(status: "skipped") unless integration.enabled?
|
|
168
|
+
|
|
169
|
+
integration.deliver(self)
|
|
170
|
+
mark_sent!
|
|
171
|
+
self
|
|
172
|
+
rescue StandardError => e
|
|
173
|
+
message = "#{e.class}: #{e.message}".first(1000)
|
|
174
|
+
update!(status: "failed", error: message)
|
|
175
|
+
integration.update_columns(last_error: message)
|
|
176
|
+
raise
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
private
|
|
180
|
+
|
|
181
|
+
def enqueue_initial_work
|
|
182
|
+
# An embedded install has no integration to deliver to and no delivery
|
|
183
|
+
# jobs to enqueue on; the alert is recorded and that is the end of it.
|
|
184
|
+
return if integration.nil?
|
|
185
|
+
|
|
186
|
+
if status == "pending"
|
|
187
|
+
enqueue_delivery!
|
|
188
|
+
elsif status == "collapsed" && alert_rule
|
|
189
|
+
SummarizeAlertBurstJob.set(wait_until: created_at + AlertRule::BURST_WINDOW).perform_later(alert_rule)
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def assign_delivery_metadata
|
|
194
|
+
self.delivery_key ||= SecureRandom.uuid
|
|
195
|
+
self.next_delivery_at ||= Time.current if status.blank? || status == "pending"
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def inherit_integration
|
|
199
|
+
self.integration ||= alert_rule&.integration
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def owns_delivery_claim?(token)
|
|
203
|
+
status == "delivering" && delivery_lease_id == token
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def finish_claim!(token, outcome)
|
|
207
|
+
with_lock do
|
|
208
|
+
return false unless owns_delivery_claim?(token)
|
|
209
|
+
|
|
210
|
+
attributes = { status: outcome, error: nil, next_delivery_at: nil,
|
|
211
|
+
delivery_lease_id: nil, delivery_lease_expires_at: nil,
|
|
212
|
+
delivery_enqueued_until: nil }
|
|
213
|
+
attributes[:sent_at] = Time.current if outcome == "sent"
|
|
214
|
+
update!(attributes)
|
|
215
|
+
if outcome == "sent"
|
|
216
|
+
integration.update_columns(last_delivered_at: Time.current, last_error: nil)
|
|
217
|
+
issue&.activities&.create!(kind: "alert", data: { event: event, integration: integration.kind })
|
|
218
|
+
end
|
|
219
|
+
true
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def mark_sent!
|
|
224
|
+
update!(status: "sent", sent_at: Time.current, error: nil)
|
|
225
|
+
integration.update_columns(last_delivered_at: Time.current, last_error: nil)
|
|
226
|
+
issue&.activities&.create!(kind: "alert", data: { event: event, integration: integration.kind })
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
end
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Railwatch
|
|
4
|
+
class AlertRule < ApplicationRecord
|
|
5
|
+
self.table_name = "railwatch_alert_rules"
|
|
6
|
+
TITLE_PATTERN_MAX_LENGTH = 256
|
|
7
|
+
TITLE_PATTERN_TIMEOUT = 0.01
|
|
8
|
+
EVENTS = %w[new_issue regressed_issue resolved_issue ignored_issue assigned_issue threshold anomaly silent_host quota crash_free_drop].freeze
|
|
9
|
+
|
|
10
|
+
# A deploy that breaks one page opens dozens of issues in a minute, and
|
|
11
|
+
# each one used to be its own Slack message. These two events are the ones
|
|
12
|
+
# that arrive in bursts.
|
|
13
|
+
BURST_EVENTS = %w[new_issue regressed_issue].freeze
|
|
14
|
+
BURST_WINDOW = 60.seconds
|
|
15
|
+
# Deliver this many individually, then fold the rest of the window into one
|
|
16
|
+
# summary message.
|
|
17
|
+
BURST_LIMIT = 5
|
|
18
|
+
|
|
19
|
+
def application = Application.current
|
|
20
|
+
def integration = nil
|
|
21
|
+
has_many :alerts, dependent: :destroy
|
|
22
|
+
|
|
23
|
+
validates :event, inclusion: { in: EVENTS }
|
|
24
|
+
validate :valid_title_pattern
|
|
25
|
+
|
|
26
|
+
# De-duplicate: one alert per issue per event per 30 minutes.
|
|
27
|
+
def fire!(event:, issue: nil, payload: {})
|
|
28
|
+
# Serialize the duplicate/burst decision per rule. The alert is born in
|
|
29
|
+
# its final outbox state, so its after-commit hook can never enqueue a row
|
|
30
|
+
# that is about to become collapsed.
|
|
31
|
+
with_lock do
|
|
32
|
+
return if issue && alerts.where(issue: issue, event: event).where("created_at > ?", 30.minutes.ago).exists?
|
|
33
|
+
|
|
34
|
+
status = bursting?(event, issue) ? "collapsed" : "pending"
|
|
35
|
+
alerts.create!(event: event, issue: issue, payload: payload, status: status)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Optional narrowing of what this rule notifies about, stored in `filters`:
|
|
40
|
+
# environment_ids, kinds, min_priority, title_pattern. An absent or empty
|
|
41
|
+
# filter matches everything; an issue-less alert (quota, silent_host) is
|
|
42
|
+
# only narrowed by environment, via payload[:environment_id].
|
|
43
|
+
def matches?(issue:, payload: {})
|
|
44
|
+
return false unless environment_matches?(issue, payload)
|
|
45
|
+
return true unless issue
|
|
46
|
+
kind_matches?(issue) && priority_matches?(issue) && title_matches?(issue)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def valid_title_pattern
|
|
52
|
+
pattern = filters["title_pattern"].to_s
|
|
53
|
+
return if pattern.blank?
|
|
54
|
+
|
|
55
|
+
if pattern.length > TITLE_PATTERN_MAX_LENGTH
|
|
56
|
+
errors.add(:filters, "title pattern is too long (maximum is #{TITLE_PATTERN_MAX_LENGTH} characters)")
|
|
57
|
+
return
|
|
58
|
+
end
|
|
59
|
+
return unless regular_expression_pattern?(pattern)
|
|
60
|
+
|
|
61
|
+
compile_title_pattern(pattern)
|
|
62
|
+
rescue RegexpError => e
|
|
63
|
+
errors.add(:filters, "title pattern is not a valid regular expression: #{e.message}")
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# True once this rule has already fired BURST_LIMIT issue alerts inside the
|
|
67
|
+
# window. Only alerts that carry an issue are counted, so summary rows can
|
|
68
|
+
# never inflate the next window's tally.
|
|
69
|
+
def bursting?(event, issue)
|
|
70
|
+
return false unless BURST_EVENTS.include?(event) && issue
|
|
71
|
+
alerts.where(event: event, created_at: BURST_WINDOW.ago..).where.not(issue_id: nil).count >= BURST_LIMIT
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def environment_matches?(issue, payload)
|
|
75
|
+
ids = Array(filters["environment_ids"]).compact_blank.map(&:to_i)
|
|
76
|
+
return true if ids.empty?
|
|
77
|
+
ids.include?((issue&.environment_id || payload[:environment_id] || payload["environment_id"]).to_i)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def kind_matches?(issue)
|
|
81
|
+
kinds = Array(filters["kinds"]).compact_blank
|
|
82
|
+
kinds.empty? || kinds.include?(issue.kind)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def priority_matches?(issue)
|
|
86
|
+
minimum = filters["min_priority"]
|
|
87
|
+
return true if minimum.blank? || Issue::PRIORITIES.exclude?(minimum)
|
|
88
|
+
Issue::PRIORITIES.index(issue.priority) >= Issue::PRIORITIES.index(minimum)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# A pattern wrapped in slashes is a regular expression, anything else is a
|
|
92
|
+
# case-insensitive substring.
|
|
93
|
+
def title_matches?(issue)
|
|
94
|
+
pattern = filters["title_pattern"].to_s
|
|
95
|
+
return true if pattern.blank?
|
|
96
|
+
if regular_expression_pattern?(pattern)
|
|
97
|
+
begin
|
|
98
|
+
return issue.title.match?(compile_title_pattern(pattern))
|
|
99
|
+
rescue Regexp::TimeoutError
|
|
100
|
+
# A pattern that backtracks past its budget matches nothing rather
|
|
101
|
+
# than stalling every alert behind it.
|
|
102
|
+
Rails.logger.warn("alert rule title pattern timed out alert_rule_id=#{id}")
|
|
103
|
+
return false
|
|
104
|
+
rescue RegexpError
|
|
105
|
+
# An unparsable regexp degrades to a substring match on its source, so
|
|
106
|
+
# a rule saved before this was validated does not silently go dark.
|
|
107
|
+
pattern = pattern[1..-2]
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
issue.title.downcase.include?(pattern.downcase)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def regular_expression_pattern?(pattern)
|
|
114
|
+
pattern.start_with?("/") && pattern.end_with?("/") && pattern.length > 2
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def compile_title_pattern(pattern)
|
|
118
|
+
Regexp.new(pattern[1..-2], Regexp::IGNORECASE, timeout: TITLE_PATTERN_TIMEOUT)
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Railwatch
|
|
4
|
+
# "Alert when p95 of GET /checkout is more than 3 standard deviations above
|
|
5
|
+
# its 7-day baseline for the same time of day." Evaluated by
|
|
6
|
+
# DetectAnomaliesJob against hourly rollups.
|
|
7
|
+
class AnomalyRule < ApplicationRecord
|
|
8
|
+
self.table_name = "railwatch_anomaly_rules"
|
|
9
|
+
MIN_BASELINE_DAYS = 3
|
|
10
|
+
MAX_BASELINE_DAYS = 30
|
|
11
|
+
MAX_DEVIATION = 10
|
|
12
|
+
MAX_WINDOW_MINUTES = 1_440
|
|
13
|
+
TARGET_KINDS = %w[requests jobs queries outgoing_requests scheduled_tasks].freeze
|
|
14
|
+
METRICS = %w[p95 avg count error_rate].freeze
|
|
15
|
+
|
|
16
|
+
def environment = Environment.current
|
|
17
|
+
|
|
18
|
+
validates :target_kind, inclusion: { in: TARGET_KINDS }
|
|
19
|
+
validates :metric, inclusion: { in: METRICS }
|
|
20
|
+
validates :target, presence: true, length: { maximum: 256 }
|
|
21
|
+
validates :deviation, numericality: { greater_than: 0, less_than_or_equal_to: MAX_DEVIATION }
|
|
22
|
+
validates :window_minutes, numericality: { only_integer: true, greater_than: 0,
|
|
23
|
+
less_than_or_equal_to: MAX_WINDOW_MINUTES }
|
|
24
|
+
validates :baseline_days, numericality: { only_integer: true, greater_than_or_equal_to: MIN_BASELINE_DAYS,
|
|
25
|
+
less_than_or_equal_to: MAX_BASELINE_DAYS }
|
|
26
|
+
|
|
27
|
+
scope :enabled, -> { where(enabled: true) }
|
|
28
|
+
|
|
29
|
+
def description
|
|
30
|
+
"#{target_kind} #{target == '*' ? 'all' : target}: #{metric} > #{deviation}σ above #{baseline_days}-day baseline"
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Railwatch
|
|
4
|
+
# The one application an embedded install monitors: the host. Not a table.
|
|
5
|
+
# Issue keys need a prefix and a counter; the rest of what the platform's
|
|
6
|
+
# Application carries (account, repository links, alert rules) is either
|
|
7
|
+
# fixed here or empty.
|
|
8
|
+
class Application
|
|
9
|
+
ID = 1
|
|
10
|
+
|
|
11
|
+
attr_reader :id, :name, :slug
|
|
12
|
+
|
|
13
|
+
def self.current = @current ||= new
|
|
14
|
+
def self.find(id)
|
|
15
|
+
raise ActiveRecord::RecordNotFound, "Application #{id}" unless id.to_i == ID
|
|
16
|
+
current
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def initialize
|
|
20
|
+
@id = ID
|
|
21
|
+
@name = Rails.application.class.module_parent_name
|
|
22
|
+
@slug = @name.parameterize
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def issue_prefix = Railwatch.config.issue_prefix || @name.upcase.gsub(/[^A-Z0-9]/, "")[0, 4].presence || "APP"
|
|
26
|
+
def repository_url = Railwatch.config.repository_url
|
|
27
|
+
def default_branch = "main"
|
|
28
|
+
def environments = [ Environment.current ]
|
|
29
|
+
def alert_rules = AlertRule.where(application_id: ID)
|
|
30
|
+
def account = Railwatch::Embedded::Account
|
|
31
|
+
def issues = Issue.where(application_id: ID)
|
|
32
|
+
|
|
33
|
+
# Issue numbers are per application on the platform (APP-1, APP-2, ...),
|
|
34
|
+
# allocated under a row lock on the application. There is no row here, so
|
|
35
|
+
# the meta database's own write lock serialises it: Issue creation already
|
|
36
|
+
# runs inside a transaction, and SQLite allows one writer.
|
|
37
|
+
def next_issue_number!
|
|
38
|
+
(Issue.where(application_id: ID).maximum(:number) || 0) + 1
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
include GlobalID::Identification
|
|
42
|
+
def to_global_id(...) = GlobalID.create(self, app: "railwatch")
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Railwatch
|
|
4
|
+
# Base class for what the engine owns about the monitored application besides
|
|
5
|
+
# telemetry: issues and their comments and activity, deploys, saved views,
|
|
6
|
+
# thresholds, anomaly rules, alert rules and alerts. These are authored and
|
|
7
|
+
# permanent where telemetry is derived and pruned, so they live in their own
|
|
8
|
+
# database (`railwatch` in the host's database.yml) and never in the host's
|
|
9
|
+
# primary.
|
|
10
|
+
class ApplicationRecord < ActiveRecord::Base
|
|
11
|
+
self.abstract_class = true
|
|
12
|
+
begin
|
|
13
|
+
connects_to database: { writing: :railwatch, reading: :railwatch }
|
|
14
|
+
rescue ActiveRecord::AdapterNotSpecified, LoadError
|
|
15
|
+
# No `railwatch` entry in this environment's database.yml, or an entry
|
|
16
|
+
# whose adapter gem is not in the bundle yet (LoadError). A cloud-transport
|
|
17
|
+
# app has none and still eager-loads this class in production, and so
|
|
18
|
+
# does the --local installer's own boot, before it has written the
|
|
19
|
+
# entry -- so loading must not raise. Using it must, though: without
|
|
20
|
+
# connects_to this class would inherit ActiveRecord::Base's PRIMARY
|
|
21
|
+
# connection, and its tables are unprefixed, so a query would read and
|
|
22
|
+
# a write would corrupt the host application's own tables. Every route
|
|
23
|
+
# into the connection goes through connection_pool, so refusing here
|
|
24
|
+
# fails closed for reads and writes alike.
|
|
25
|
+
def self.connection_pool
|
|
26
|
+
raise Railwatch::DatabaseNotConfigured,
|
|
27
|
+
"the `railwatch` database (issues, comments, saved views and deploys) is not configured for the " \
|
|
28
|
+
"#{Rails.env} environment; run `bin/rails generate railwatch:install --local` " \
|
|
29
|
+
"or add it to config/database.yml (docs/embedded.md)"
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Railwatch
|
|
4
|
+
class Comment < ApplicationRecord
|
|
5
|
+
self.table_name = "railwatch_comments"
|
|
6
|
+
SOURCES = %w[railwatch linear].freeze
|
|
7
|
+
|
|
8
|
+
belongs_to :issue
|
|
9
|
+
def user
|
|
10
|
+
viewer_id && User.find_by(id: viewer_id)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def user=(u)
|
|
14
|
+
self.viewer_id = u&.id&.to_s
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
validates :body, presence: true
|
|
18
|
+
validates :source, inclusion: { in: SOURCES }
|
|
19
|
+
validates :user, presence: true, if: -> { source == "railwatch" }
|
|
20
|
+
validates :author_name, presence: true, if: -> { source == "linear" }
|
|
21
|
+
|
|
22
|
+
after_create :record_activity
|
|
23
|
+
after_create_commit :enqueue_linear_sync
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def record_activity
|
|
28
|
+
issue.activities.create!(kind: "comment", user: user,
|
|
29
|
+
data: { comment_id: id, body: body, source: source, author_name: author_name })
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def enqueue_linear_sync
|
|
33
|
+
nil unless source == "railwatch"
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|