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,170 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Railwatch
|
|
4
|
+
module Ingest
|
|
5
|
+
# Folds one batch's rows into the hourly rollups as they land, so a count
|
|
6
|
+
# or a percentile on the dashboard moves with every batch instead of
|
|
7
|
+
# waiting for RollupJob to recompute the hour from raw rows. Same
|
|
8
|
+
# classification as RollupJob (which stays as the reconciler for anything
|
|
9
|
+
# this missed), applied to the mapped row hashes before they are inserted
|
|
10
|
+
# rather than to Active Record rows read back afterwards.
|
|
11
|
+
#
|
|
12
|
+
# One SELECT for the groups this batch touches, the merge in Ruby, one
|
|
13
|
+
# upsert: the read and the write both stay small whatever the hour holds,
|
|
14
|
+
# so this never grows into the 100 ms recompute that RollupJob is on a
|
|
15
|
+
# busy hour. It runs inside the batch's transaction.
|
|
16
|
+
class RollupAbsorber
|
|
17
|
+
# record_type => [table class, row filter, group name]. Executions carry
|
|
18
|
+
# their kind on the row; LLM calls split into models and tools.
|
|
19
|
+
SOURCES = {
|
|
20
|
+
"request" => [ Telemetry::Execution, ->(r) { r[:kind] == "request" }, ->(r) { r[:name] } ],
|
|
21
|
+
"job_attempt" => [ Telemetry::Execution, ->(r) { r[:kind] == "job_attempt" }, ->(r) { r[:name] } ],
|
|
22
|
+
"scheduled_task" => [ Telemetry::Execution, ->(r) { r[:kind] == "scheduled_task" }, ->(r) { r[:name] } ],
|
|
23
|
+
"command" => [ Telemetry::Execution, ->(r) { r[:kind] == "command" }, ->(r) { r[:name] } ],
|
|
24
|
+
"channel_action" => [ Telemetry::Execution, ->(r) { r[:kind] == "channel_action" }, ->(r) { r[:name] } ],
|
|
25
|
+
"query" => [ Telemetry::Query, ->(_r) { true }, ->(r) { r[:sql].to_s } ],
|
|
26
|
+
"outgoing_request" => [ Telemetry::OutgoingRequest, ->(_r) { true }, ->(r) { "#{r[:method]} #{r[:host]}" } ],
|
|
27
|
+
"cache_event" => [ Telemetry::CacheEvent, ->(_r) { true }, ->(r) { "#{r[:store]} #{r[:key]}" } ],
|
|
28
|
+
"mail" => [ Telemetry::Mail, ->(_r) { true }, ->(r) { r[:mailer] } ],
|
|
29
|
+
"visit" => [ Telemetry::Visit, ->(_r) { true }, ->(r) { r[:component] } ],
|
|
30
|
+
"span" => [ Telemetry::Span, ->(_r) { true }, ->(r) { r[:name] } ],
|
|
31
|
+
"notification" => [ Telemetry::Notification, ->(_r) { true }, ->(r) { r[:notifier] || r[:delivery_method] } ],
|
|
32
|
+
"view_render" => [ Telemetry::ViewRender, ->(_r) { true }, ->(r) { r[:identifier] } ],
|
|
33
|
+
"transaction" => [ Telemetry::Transaction, ->(_r) { true }, ->(r) { "#{r[:connection]} · #{r[:outcome]}" } ],
|
|
34
|
+
"llm_call" => [ Telemetry::LlmCall, ->(r) { r[:operation] != Telemetry::LlmCall::TOOL }, ->(r) { "#{r[:model]} · #{r[:operation]}" } ],
|
|
35
|
+
"llm_tool" => [ Telemetry::LlmCall, ->(r) { r[:operation] == Telemetry::LlmCall::TOOL }, ->(r) { r[:tool_name].to_s } ]
|
|
36
|
+
}.freeze
|
|
37
|
+
|
|
38
|
+
# SQLite binds at most 32,766 variables per statement; a rollup row is 14.
|
|
39
|
+
UPSERT_SLICE = 500
|
|
40
|
+
|
|
41
|
+
def initialize(rows_by_class, query_shapes: {})
|
|
42
|
+
@rows_by_class = rows_by_class
|
|
43
|
+
# Writer files a query's text on its shape and blanks the row, so the
|
|
44
|
+
# group's name has to come from the shape when the row is empty.
|
|
45
|
+
@query_shapes = query_shapes
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Groups every rolled-up row by (type, group, hour), merges each group
|
|
49
|
+
# into its existing rollup row if there is one, and writes them back.
|
|
50
|
+
def absorb!
|
|
51
|
+
pending = collect
|
|
52
|
+
return 0 if pending.empty?
|
|
53
|
+
|
|
54
|
+
existing = load_existing(pending.keys)
|
|
55
|
+
rows = pending.map { |key, group| merged_row(key, group, existing[key]) }
|
|
56
|
+
rows.each_slice(UPSERT_SLICE) do |slice|
|
|
57
|
+
Telemetry::Rollup.upsert_all(slice, unique_by: %i[record_type group_hash bucket], record_timestamps: false)
|
|
58
|
+
end
|
|
59
|
+
rows.size
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
# { [type, group_hash, bucket] => { name:, durations:, errors:, client_errors:, extra: } }
|
|
65
|
+
def collect
|
|
66
|
+
pending = {}
|
|
67
|
+
SOURCES.each do |type, (klass, filter, namer)|
|
|
68
|
+
@rows_by_class.fetch(klass, []).each do |row|
|
|
69
|
+
next unless filter.call(row)
|
|
70
|
+
group_hash = row[:group_hash]
|
|
71
|
+
duration = row[:duration]
|
|
72
|
+
next if group_hash.nil? || duration.nil?
|
|
73
|
+
|
|
74
|
+
bucket = bucket_for(row[:occurred_at])
|
|
75
|
+
next unless bucket
|
|
76
|
+
|
|
77
|
+
entry = pending[[ type, group_hash, bucket ]] ||= { name: nil, durations: [], errors: 0, client_errors: 0, extra: Hash.new(0) }
|
|
78
|
+
entry[:name] ||= name_for(type, row, namer)
|
|
79
|
+
entry[:durations] << duration.to_i
|
|
80
|
+
entry[:errors] += 1 if error?(type, row)
|
|
81
|
+
entry[:client_errors] += 1 if type == "request" && row[:status].to_i.between?(400, 499)
|
|
82
|
+
add_extra(type, row, entry[:extra])
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
pending
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def load_existing(keys)
|
|
89
|
+
found = {}
|
|
90
|
+
keys.group_by { |type, _group, bucket| [ type, bucket ] }.each do |(type, bucket), group_keys|
|
|
91
|
+
Telemetry::Rollup.where(record_type: type, bucket: bucket, group_hash: group_keys.map { |k| k[1] })
|
|
92
|
+
.each { |row| found[[ type, row.group_hash, bucket ]] = row }
|
|
93
|
+
end
|
|
94
|
+
found
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def merged_row((type, group_hash, bucket), group, existing)
|
|
98
|
+
digest = existing&.digest ? TDigest::TDigest.from_bytes(existing.digest) : TDigest::TDigest.new(0.01)
|
|
99
|
+
group[:durations].each { |d| digest.push(d) }
|
|
100
|
+
digest.compress!
|
|
101
|
+
extra = existing ? existing.extra.merge(group[:extra]) { |_k, a, b| a.is_a?(Numeric) && b.is_a?(Numeric) ? a + b : b } : group[:extra].to_h
|
|
102
|
+
{
|
|
103
|
+
record_type: type, group_hash: group_hash, bucket: bucket,
|
|
104
|
+
name: (group[:name].presence || existing&.name || "").to_s.first(255),
|
|
105
|
+
count: existing&.count.to_i + group[:durations].size,
|
|
106
|
+
error_count: existing&.error_count.to_i + group[:errors],
|
|
107
|
+
client_error_count: existing&.client_error_count.to_i + group[:client_errors],
|
|
108
|
+
duration_sum: existing&.duration_sum.to_i + group[:durations].sum,
|
|
109
|
+
duration_max: [ existing&.duration_max.to_i, group[:durations].max ].max,
|
|
110
|
+
p50: digest.percentile(0.5).to_i, p95: digest.percentile(0.95).to_i, p99: digest.percentile(0.99).to_i,
|
|
111
|
+
digest: digest.as_small_bytes, extra: extra
|
|
112
|
+
}
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Mapper writes occurred_at as a UTC string; the hour is its prefix.
|
|
116
|
+
def bucket_for(occurred_at)
|
|
117
|
+
return nil if occurred_at.nil?
|
|
118
|
+
|
|
119
|
+
@buckets ||= {}
|
|
120
|
+
@buckets[occurred_at.to_s[0, 13]] ||= Time.zone.parse(occurred_at.to_s)&.utc&.beginning_of_hour
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def name_for(type, row, namer)
|
|
124
|
+
if type == "query"
|
|
125
|
+
text = row[:sql].to_s
|
|
126
|
+
text = @query_shapes[row[:group_hash]].to_s if text.empty?
|
|
127
|
+
text.first(255)
|
|
128
|
+
else
|
|
129
|
+
namer.call(row).to_s
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def error?(type, row)
|
|
134
|
+
case type
|
|
135
|
+
when "request" then row[:status].to_i >= 500
|
|
136
|
+
when "job_attempt", "scheduled_task", "channel_action" then row[:outcome] == "failed"
|
|
137
|
+
when "command" then row[:status].to_i != 0
|
|
138
|
+
when "outgoing_request" then row[:status_code].to_i >= 500 || row[:status_code].to_i.zero?
|
|
139
|
+
when "mail", "notification" then row[:failed] ? true : false
|
|
140
|
+
when "visit" then row[:status] == "error"
|
|
141
|
+
when "transaction" then row[:outcome] == "rollback"
|
|
142
|
+
when "span", "llm_call", "llm_tool" then row[:status] == "failed"
|
|
143
|
+
else false
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def add_extra(type, row, extra)
|
|
148
|
+
case type
|
|
149
|
+
when "cache_event"
|
|
150
|
+
extra["hits"] += 1 if row[:type] == "hit"
|
|
151
|
+
extra["misses"] += 1 if row[:type] == "miss" || row[:type] == "generate"
|
|
152
|
+
when "view_render"
|
|
153
|
+
# RollupJob stores the hour's commonest kind; a batch cannot know
|
|
154
|
+
# that, so the latest seen wins. Same shape, close enough.
|
|
155
|
+
extra["kind"] = row[:kind] if row[:kind]
|
|
156
|
+
when "llm_call"
|
|
157
|
+
extra["input_tokens"] += row[:input_tokens].to_i
|
|
158
|
+
extra["output_tokens"] += row[:output_tokens].to_i
|
|
159
|
+
extra["cache_read_tokens"] += row[:cache_read_tokens].to_i
|
|
160
|
+
extra["cache_write_tokens"] += row[:cache_write_tokens].to_i
|
|
161
|
+
extra["cost_nanos"] += row[:cost_nanos].to_i
|
|
162
|
+
extra["priced"] += 1 if row[:cost_nanos]
|
|
163
|
+
extra["unpriced"] += 1 if row[:cost_nanos].nil?
|
|
164
|
+
extra["truncated"] += 1 if row[:finish_reason] == "max_tokens"
|
|
165
|
+
extra["with_attachments"] += 1 if row[:attachments].to_i.positive?
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Railwatch
|
|
4
|
+
module Ingest
|
|
5
|
+
# Inserts mapped rows (klass => [row_hash, ...], from Ingest::Mapper.row_for)
|
|
6
|
+
# into the current tenant's telemetry database inside one transaction.
|
|
7
|
+
#
|
|
8
|
+
# On SQLite, binds straight into a prepared statement per (table, column
|
|
9
|
+
# set) instead of going through Active Record's insert machinery — this is
|
|
10
|
+
# the fast path the platform is tuned for. On any other adapter (Postgres
|
|
11
|
+
# stays supported per SCOPE.md), falls back to Active Record's insert_all.
|
|
12
|
+
class Writer
|
|
13
|
+
# Classes whose new rowids we need after the insert: exception ids go
|
|
14
|
+
# back to the caller for grouping, log ids into the full-text index.
|
|
15
|
+
ID_CLASSES = [ Telemetry::Exception, Telemetry::Log ].freeze
|
|
16
|
+
|
|
17
|
+
def initialize(rows_by_class)
|
|
18
|
+
@rows_by_class = rows_by_class
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# group_hash => statement, for the queries this batch filed on their
|
|
22
|
+
# shape; RollupAbsorber names a query group from it.
|
|
23
|
+
attr_reader :query_shapes
|
|
24
|
+
|
|
25
|
+
def write!
|
|
26
|
+
exception_ids = []
|
|
27
|
+
TelemetryRecord.transaction do
|
|
28
|
+
tables = with_query_shapes(@rows_by_class)
|
|
29
|
+
connection = TelemetryRecord.connection
|
|
30
|
+
exception_ids = connection.adapter_name == "SQLite" ? write_sqlite(connection, tables) : write_generic(tables)
|
|
31
|
+
end
|
|
32
|
+
exception_ids
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
# A query's group is the gem's digest of its connection name and its
|
|
38
|
+
# normalized statement, which is the text the gem sends unless it is
|
|
39
|
+
# configured to capture values. A row whose text digests to its group
|
|
40
|
+
# therefore carries the group's statement: it is filed once in
|
|
41
|
+
# query_shapes and the row stores "". Any other text (captured values,
|
|
42
|
+
# a statement the gem truncated, an older gem's raw text) stays on the
|
|
43
|
+
# row. One MD5 per row; the shapes ride the same INSERT OR IGNORE as
|
|
44
|
+
# every table, so a group already on file costs nothing.
|
|
45
|
+
def with_query_shapes(rows_by_class)
|
|
46
|
+
shapes = {}
|
|
47
|
+
rows_by_class.fetch(Telemetry::Query, []).each do |row|
|
|
48
|
+
group_hash = row[:group_hash]
|
|
49
|
+
next unless group_hash && row[:sql].present? && Railwatch::Record.group_hash(row[:connection], row[:sql]) == group_hash
|
|
50
|
+
|
|
51
|
+
shapes[group_hash] ||= { group_hash: group_hash, sql: row[:sql] }
|
|
52
|
+
row[:sql] = ""
|
|
53
|
+
end
|
|
54
|
+
@query_shapes = shapes.transform_values { |shape| shape[:sql] }
|
|
55
|
+
shapes.empty? ? rows_by_class : rows_by_class.merge(Telemetry::QueryShape => shapes.values)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def write_sqlite(connection, tables)
|
|
59
|
+
raw = connection.raw_connection
|
|
60
|
+
ids = Hash.new { |h, k| h[k] = [] }
|
|
61
|
+
tables.each do |klass, rows|
|
|
62
|
+
next if rows.empty?
|
|
63
|
+
table = connection.quote_table_name(klass.table_name)
|
|
64
|
+
rows.group_by(&:keys).each do |columns, group_rows|
|
|
65
|
+
ids[klass].concat(insert_group(raw, connection, table, columns, group_rows, klass))
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
index_logs(raw, ids[Telemetry::Log])
|
|
69
|
+
ids[Telemetry::Exception]
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def insert_group(raw, connection, table, columns, rows, klass)
|
|
73
|
+
column_sql = columns.map { |c| connection.quote_column_name(c.to_s) }.join(",")
|
|
74
|
+
placeholders = ([ "?" ] * columns.size).join(",")
|
|
75
|
+
# The gem re-sends a batch after a transport timeout, so the same
|
|
76
|
+
# execution can arrive twice; the unique index on execution_id turns
|
|
77
|
+
# the duplicate into a no-op instead of aborting the whole batch.
|
|
78
|
+
stmt = raw.prepare("INSERT OR IGNORE INTO #{table} (#{column_sql}) VALUES (#{placeholders})")
|
|
79
|
+
ids = []
|
|
80
|
+
begin
|
|
81
|
+
rows.each do |row|
|
|
82
|
+
stmt.bind_params(*columns.map { |c| row[c] })
|
|
83
|
+
stmt.step
|
|
84
|
+
ids << raw.last_insert_row_id if ID_CLASSES.include?(klass) && raw.changes.positive?
|
|
85
|
+
stmt.reset!
|
|
86
|
+
end
|
|
87
|
+
ensure
|
|
88
|
+
stmt.close
|
|
89
|
+
end
|
|
90
|
+
ids
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# logs_fts is an external-content FTS5 table declared without triggers
|
|
94
|
+
# (the schema dumper cannot carry them), so every inserted message has to
|
|
95
|
+
# be copied into the index by hand, inside the same transaction as the
|
|
96
|
+
# rows themselves. Only SQLite has the index; write_generic does nothing.
|
|
97
|
+
def index_logs(raw, ids)
|
|
98
|
+
# A tenant database migrated before the index existed, or one restored
|
|
99
|
+
# from a backup that predates it, must not fail every batch that carries
|
|
100
|
+
# a log line; telemetry:fts:rebuild creates the index later.
|
|
101
|
+
return if ids.empty? || !Telemetry::Log.fts_available?
|
|
102
|
+
|
|
103
|
+
ids.each_slice(500) do |slice|
|
|
104
|
+
placeholders = ([ "?" ] * slice.size).join(",")
|
|
105
|
+
raw.execute("INSERT INTO logs_fts(rowid, message) SELECT id, message FROM logs WHERE id IN (#{placeholders})", slice)
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def write_generic(tables)
|
|
110
|
+
exception_ids = []
|
|
111
|
+
tables.each do |klass, rows|
|
|
112
|
+
next if rows.empty?
|
|
113
|
+
result = klass.insert_all(restore_json(klass, rows), record_timestamps: false,
|
|
114
|
+
unique_by: (:execution_id if klass == Telemetry::Execution),
|
|
115
|
+
returning: klass == Telemetry::Exception ? [ :id ] : false)
|
|
116
|
+
exception_ids.concat(result.rows.flatten) if klass == Telemetry::Exception
|
|
117
|
+
end
|
|
118
|
+
exception_ids
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# insert_all round-trips every value through its column's type (cast
|
|
122
|
+
# then serialize) before quoting it, which is correct for booleans
|
|
123
|
+
# (0/1 casts back to false/true) and timestamps (the formatted string
|
|
124
|
+
# parses back to a Time) but not for a JSON column: its mutable type
|
|
125
|
+
# re-encodes a String as-is instead of parsing it, which would double-
|
|
126
|
+
# encode our pre-serialized JSON. Only this fallback needs the fix —
|
|
127
|
+
# the SQLite fast path writes the encoded string straight through.
|
|
128
|
+
def restore_json(klass, rows)
|
|
129
|
+
json_columns = Ingest::Mapper.columns_for(klass).select { |_, (type, _)| type == :json }.keys.map(&:to_sym)
|
|
130
|
+
return rows if json_columns.empty?
|
|
131
|
+
rows.map do |row|
|
|
132
|
+
row.dup.tap { |r| json_columns.each { |c| r[c] = JSON.parse(r[c]) if r[c].is_a?(String) } }
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Railwatch
|
|
4
|
+
# A grouped exception or performance problem with a lifecycle. Lives in the
|
|
5
|
+
# primary database so it keeps its sequential id after telemetry is pruned.
|
|
6
|
+
class Issue < ApplicationRecord
|
|
7
|
+
self.table_name = "railwatch_issues"
|
|
8
|
+
class InvalidMerge < ArgumentError; end
|
|
9
|
+
|
|
10
|
+
KINDS = %w[exception performance anomaly].freeze
|
|
11
|
+
STATUSES = %w[open resolved ignored merged].freeze
|
|
12
|
+
PRIORITIES = %w[low normal high urgent].freeze
|
|
13
|
+
# How many recent occurrences a split by message looks at.
|
|
14
|
+
SPLIT_SAMPLE = 200
|
|
15
|
+
MERGE_CHAIN_LIMIT = 10
|
|
16
|
+
|
|
17
|
+
def application = Application.current
|
|
18
|
+
def environment = Environment.current
|
|
19
|
+
# assignee is a plain id on an embedded install (User is not a table).
|
|
20
|
+
def assignee
|
|
21
|
+
assignee_id && User.find_by(id: assignee_id)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def assignee=(user)
|
|
25
|
+
self.assignee_id = user&.id&.to_s
|
|
26
|
+
end
|
|
27
|
+
belongs_to :merged_into, class_name: "Issue", optional: true
|
|
28
|
+
has_many :merged_issues, class_name: "Issue", foreign_key: :merged_into_id, inverse_of: :merged_into, dependent: :nullify
|
|
29
|
+
has_many :comments, dependent: :destroy
|
|
30
|
+
has_many :alerts, dependent: :nullify
|
|
31
|
+
has_many :activities, class_name: "IssueActivity", dependent: :destroy
|
|
32
|
+
|
|
33
|
+
validates :kind, inclusion: { in: KINDS }
|
|
34
|
+
validates :status, inclusion: { in: STATUSES }
|
|
35
|
+
validates :priority, inclusion: { in: PRIORITIES }
|
|
36
|
+
validates :group_hash, presence: true, uniqueness: { scope: :environment_id }
|
|
37
|
+
validates :title, presence: true
|
|
38
|
+
|
|
39
|
+
before_validation :assign_number, on: :create
|
|
40
|
+
after_create :record_created_activity
|
|
41
|
+
after_update :record_priority_activity
|
|
42
|
+
after_update :record_assignee_activity
|
|
43
|
+
|
|
44
|
+
scope :open, -> { where(status: "open") }
|
|
45
|
+
scope :recent, -> { order(last_seen_at: :desc) }
|
|
46
|
+
|
|
47
|
+
def key
|
|
48
|
+
"#{application.issue_prefix}-#{number}"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def open? = status == "open"
|
|
52
|
+
def resolved? = status == "resolved"
|
|
53
|
+
def merged? = status == "merged"
|
|
54
|
+
|
|
55
|
+
def resolve!(deploy: nil)
|
|
56
|
+
record_status_change! { update!(status: "resolved", resolved_at: Time.current, resolved_in_deploy: deploy) }
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def ignore!
|
|
60
|
+
record_status_change! { update!(status: "ignored") }
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def reopen!
|
|
64
|
+
record_status_change! { update!(status: "open", resolved_at: nil, resolved_in_deploy: nil) }
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Merges this issue into `target`: moves comments over, folds occurrence
|
|
68
|
+
# and affected-user counts into the target, widens its first/last seen
|
|
69
|
+
# range, and marks this issue "merged". Future occurrences for this
|
|
70
|
+
# issue's group_hash are then credited to the target (see
|
|
71
|
+
# .record_occurrence!). Same environment only.
|
|
72
|
+
def merge_into!(target)
|
|
73
|
+
raise InvalidMerge, "both issues must be persisted before merging" unless persisted? && target&.persisted?
|
|
74
|
+
|
|
75
|
+
transaction do
|
|
76
|
+
# Every merge locks the same pair in the same order. Besides preventing
|
|
77
|
+
# reciprocal requests from creating a cycle, lock! reloads both rows so
|
|
78
|
+
# folding counts cannot overwrite a merge committed from a stale object.
|
|
79
|
+
[ self, target ].sort_by(&:id).each(&:lock!)
|
|
80
|
+
validate_merge_target!(target)
|
|
81
|
+
|
|
82
|
+
comments.update_all(issue_id: target.id)
|
|
83
|
+
target.update!(occurrences: target.occurrences + occurrences, affected_users: [ target.affected_users, affected_users ].max,
|
|
84
|
+
first_seen_at: [ target.first_seen_at, first_seen_at ].min, last_seen_at: [ target.last_seen_at, last_seen_at ].max)
|
|
85
|
+
update!(status: "merged", merged_into_id: target.id)
|
|
86
|
+
activities.create!(kind: "merge", user: Viewer.user, data: { target_id: target.id, target_key: target.key })
|
|
87
|
+
target.activities.create!(kind: "absorbed", user: Viewer.user, data: { source_id: id, source_key: key, occurrences: occurrences })
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Gives the merged-in events back: occurrences counted on the target since
|
|
92
|
+
# the merge stay there (they were credited under the target's own group),
|
|
93
|
+
# only the count that came along at merge time moves back. Chained merges
|
|
94
|
+
# must be unwound from the live end so an inner source cannot be restored
|
|
95
|
+
# while its count is still included farther up the chain.
|
|
96
|
+
def unmerge!
|
|
97
|
+
transaction do
|
|
98
|
+
target_id = self.class.where(id: id).pick(:merged_into_id)
|
|
99
|
+
raise InvalidMerge, "cannot unmerge an issue that is not merged" unless target_id
|
|
100
|
+
|
|
101
|
+
target = self.class.find(target_id)
|
|
102
|
+
[ self, target ].sort_by(&:id).each(&:lock!)
|
|
103
|
+
unless merged? && merged_into_id == target.id
|
|
104
|
+
raise InvalidMerge, "issue merge changed; reload and try again"
|
|
105
|
+
end
|
|
106
|
+
if target.merged? || target.merged_into_id.present?
|
|
107
|
+
raise InvalidMerge, "cannot unmerge this issue yet; unmerge #{target.key} first (top-down order)"
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
target.update!(occurrences: [ target.occurrences - occurrences, 0 ].max)
|
|
111
|
+
update!(status: "open", merged_into_id: nil)
|
|
112
|
+
activities.create!(kind: "unmerge", user: Viewer.user, data: { target_id: target.id, target_key: target.key })
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Splits this issue by the raw message of its recent occurrences: one new
|
|
117
|
+
# issue per distinct message, each taking that message's count, seen range
|
|
118
|
+
# and sample, with this issue giving those occurrences up. Nothing moves in
|
|
119
|
+
# the telemetry database -- the rows are still grouped under the hash the
|
|
120
|
+
# gem sent -- so this separates what has already been seen; to split future
|
|
121
|
+
# occurrences too, give the error a real fingerprint (Railwatch.fingerprint).
|
|
122
|
+
# Returns the issues it created: [] when every recent occurrence carries
|
|
123
|
+
# the same message, or when they have all been split off already.
|
|
124
|
+
def split!(by: "message")
|
|
125
|
+
return [] unless by == "message"
|
|
126
|
+
|
|
127
|
+
rows = environment.with_telemetry { Telemetry::Exception.where(group_hash: group_hash).recent.limit(SPLIT_SAMPLE).to_a }
|
|
128
|
+
groups = rows.group_by { |row| row.message.to_s }
|
|
129
|
+
return [] if groups.size < 2
|
|
130
|
+
|
|
131
|
+
transaction do
|
|
132
|
+
pending = groups.reject { |message, _| environment.issues.exists?(group_hash: self.class.split_group_hash(group_hash, message)) }
|
|
133
|
+
created = pending.map { |message, message_rows| split_off(message, message_rows) }
|
|
134
|
+
next [] if created.empty?
|
|
135
|
+
|
|
136
|
+
moved = created.sum(&:occurrences)
|
|
137
|
+
update!(occurrences: [ occurrences - moved, 0 ].max)
|
|
138
|
+
activities.create!(kind: "split", user: Viewer.user, data: { by: by, occurrences: moved, into: created.map(&:key) })
|
|
139
|
+
created
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Mirrors the gem's Record.group_hash (MD5, 32 hex chars) but joins on a
|
|
144
|
+
# separator the gem never sends, so an issue split here can never collide
|
|
145
|
+
# with one the gem itself would group.
|
|
146
|
+
def self.split_group_hash(group_hash, message)
|
|
147
|
+
Digest::MD5.hexdigest([ group_hash, message ].join("\x1f"))[0, 32]
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Called by the grouper for every new occurrence. Returns :new, :regressed,
|
|
151
|
+
# or :seen so the caller can decide whether to alert.
|
|
152
|
+
def self.record_occurrence!(environment:, group_hash:, kind:, title:, culprit:, occurred_at:, deploy:, user_ref:, sample:, source: nil)
|
|
153
|
+
issue = find_or_initialize_by(environment_id: environment.id, group_hash: group_hash)
|
|
154
|
+
return record_occurrence_on!(issue, environment:, kind:, title:, culprit:, occurred_at:, deploy:, sample:, source:) if issue.new_record?
|
|
155
|
+
|
|
156
|
+
visited = {}
|
|
157
|
+
0.upto(MERGE_CHAIN_LIMIT) do |depth|
|
|
158
|
+
raise InvalidMerge, "cycle detected in issue merge chain" if visited[issue.id]
|
|
159
|
+
visited[issue.id] = true
|
|
160
|
+
|
|
161
|
+
next_id = nil
|
|
162
|
+
result = nil
|
|
163
|
+
# Lock only the row currently being inspected. If it has merged, release
|
|
164
|
+
# that lock before following the pointer; this avoids taking chain locks
|
|
165
|
+
# in an order that can deadlock with merge_into!.
|
|
166
|
+
issue.with_lock do
|
|
167
|
+
if issue.merged? || issue.merged_into_id.present?
|
|
168
|
+
next_id = issue.merged_into_id
|
|
169
|
+
raise InvalidMerge, "merged issue has no merge target" unless next_id
|
|
170
|
+
else
|
|
171
|
+
result = record_occurrence_on!(issue, environment:, kind:, title:, culprit:, occurred_at:, deploy:, sample:, source:)
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
return result if result
|
|
175
|
+
raise InvalidMerge, "issue merge chain exceeds #{MERGE_CHAIN_LIMIT} links" if depth == MERGE_CHAIN_LIMIT
|
|
176
|
+
|
|
177
|
+
issue = find_by(id: next_id)
|
|
178
|
+
raise InvalidMerge, "issue merge target no longer exists" unless issue
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# Routes an issue lifecycle event through every alert rule subscribed to it
|
|
183
|
+
# whose filters match. Public so detectors (DetectAnomaliesJob) can route
|
|
184
|
+
# their own events through the same path.
|
|
185
|
+
def fire_alerts!(event, extra = {})
|
|
186
|
+
return unless event
|
|
187
|
+
application.alert_rules.where(event: event).find_each do |rule|
|
|
188
|
+
payload = { issue_key: key, title: title, environment: environment.name, actor: Viewer.user&.name }.merge(extra)
|
|
189
|
+
next unless rule.matches?(issue: self, payload: payload)
|
|
190
|
+
rule.fire!(event: event, issue: self, payload: payload)
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# During the rollout the legacy one-workspace columns may already contain
|
|
195
|
+
# a link. Materialize it once so old rows enter the per-workspace model
|
|
196
|
+
# without losing synchronization.
|
|
197
|
+
def linear_links_with_legacy = []
|
|
198
|
+
|
|
199
|
+
private
|
|
200
|
+
|
|
201
|
+
def self.record_occurrence_on!(issue, environment:, kind:, title:, culprit:, occurred_at:, deploy:, sample:, source:)
|
|
202
|
+
outcome =
|
|
203
|
+
if issue.new_record?
|
|
204
|
+
issue.assign_attributes(application_id: environment.application.id, kind: kind, title: title, culprit: culprit,
|
|
205
|
+
first_seen_at: occurred_at)
|
|
206
|
+
:new
|
|
207
|
+
elsif issue.resolved? && (issue.resolved_in_deploy.nil? || deploy.to_s != issue.resolved_in_deploy)
|
|
208
|
+
issue.assign_attributes(status: "open", regressed_at: occurred_at, resolved_at: nil, resolved_in_deploy: nil)
|
|
209
|
+
:regressed
|
|
210
|
+
else
|
|
211
|
+
:seen
|
|
212
|
+
end
|
|
213
|
+
issue.occurrences += 1
|
|
214
|
+
issue.last_seen_at = occurred_at if issue.last_seen_at.nil? || occurred_at > issue.last_seen_at
|
|
215
|
+
issue.sample = sample
|
|
216
|
+
# Where the latest occurrence was raised: "browser" for a JavaScript
|
|
217
|
+
# error the beacon reported, the Rails source for everything else.
|
|
218
|
+
issue.source = source
|
|
219
|
+
issue.title = title if outcome == :new
|
|
220
|
+
issue.save!
|
|
221
|
+
issue.activities.create!(kind: "regressed", data: { at: occurred_at }) if outcome == :regressed
|
|
222
|
+
[ issue, outcome ]
|
|
223
|
+
end
|
|
224
|
+
private_class_method :record_occurrence_on!
|
|
225
|
+
|
|
226
|
+
def validate_merge_target!(target)
|
|
227
|
+
raise InvalidMerge, "can only merge issues in the same environment" unless target.environment_id == environment_id
|
|
228
|
+
raise InvalidMerge, "cannot merge an issue into itself" if target.id == id
|
|
229
|
+
raise InvalidMerge, "cannot merge an issue that is already merged" if merged? || merged_into_id.present?
|
|
230
|
+
raise InvalidMerge, "merge target must be open" unless target.open?
|
|
231
|
+
raise InvalidMerge, "merge target must not itself be merged" if target.merged_into_id.present?
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def split_off(message, rows)
|
|
235
|
+
latest = rows.max_by(&:occurred_at)
|
|
236
|
+
issue = environment.issues.create!(application_id: application.id, kind: kind, culprit: culprit, source: source,
|
|
237
|
+
group_hash: self.class.split_group_hash(group_hash, message),
|
|
238
|
+
title: "#{latest.class_name}: #{message.first(200)}", occurrences: rows.size,
|
|
239
|
+
first_seen_at: rows.min_by(&:occurred_at).occurred_at, last_seen_at: latest.occurred_at,
|
|
240
|
+
sample: { exception_id: latest.id, handled: latest.handled, execution_id: latest.execution_id,
|
|
241
|
+
execution_preview: latest.execution_preview, deploy: latest.deploy,
|
|
242
|
+
fingerprint: latest.fingerprint, fingerprint_source: latest.fingerprint_source })
|
|
243
|
+
issue.activities.create!(kind: "split", user: Viewer.user, data: { from_id: id, from_key: key, occurrences: rows.size })
|
|
244
|
+
issue
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def record_status_change!
|
|
248
|
+
from = status
|
|
249
|
+
yield
|
|
250
|
+
return if status == from
|
|
251
|
+
activities.create!(kind: "status", user: Viewer.user, data: { from: from, to: status })
|
|
252
|
+
fire_alerts!({ "resolved" => "resolved_issue", "ignored" => "ignored_issue", "open" => "regressed_issue" }[status])
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def record_created_activity
|
|
256
|
+
activities.create!(kind: "created", user: Viewer.user)
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
def record_priority_activity
|
|
260
|
+
return unless saved_change_to_priority?
|
|
261
|
+
from, to = saved_change_to_priority
|
|
262
|
+
activities.create!(kind: "priority", user: Viewer.user, data: { from: from, to: to })
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def record_assignee_activity
|
|
266
|
+
return unless saved_change_to_assignee_id?
|
|
267
|
+
from, to = saved_change_to_assignee_id
|
|
268
|
+
activities.create!(kind: "assignee", user: Viewer.user, data: { from_id: from, to_id: to })
|
|
269
|
+
fire_alerts!("assigned_issue", assignee: assignee&.name, assignee_id: to) if to
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def assign_number
|
|
274
|
+
self.number ||= application.next_issue_number!
|
|
275
|
+
end
|
|
276
|
+
end
|
|
277
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Railwatch
|
|
4
|
+
# One entry in an issue's timeline: a comment or a system event (status
|
|
5
|
+
# change, priority change, reassignment, merge/unmerge, split, regression,
|
|
6
|
+
# alert sent). `data` holds kind-specific details the frontend renders into a
|
|
7
|
+
# sentence.
|
|
8
|
+
class IssueActivity < ApplicationRecord
|
|
9
|
+
self.table_name = "railwatch_issue_activities"
|
|
10
|
+
KINDS = %w[created status priority assignee comment merge absorbed unmerge split regressed alert agent].freeze
|
|
11
|
+
|
|
12
|
+
belongs_to :issue
|
|
13
|
+
def user
|
|
14
|
+
viewer_id && User.find_by(id: viewer_id)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def user=(u)
|
|
18
|
+
self.viewer_id = u&.id&.to_s
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
validates :kind, inclusion: { in: KINDS }
|
|
22
|
+
end
|
|
23
|
+
end
|