railwatch 0.9.3 → 1.0.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/AGENTS.md +37 -33
- data/CHANGELOG.md +66 -0
- data/README.md +51 -60
- data/config/routes.rb +0 -59
- data/docs/ai-and-mcp.md +9 -8
- data/docs/configuration.md +57 -71
- data/docs/faq.md +17 -25
- data/docs/getting-started.md +105 -117
- data/docs/replacing-nightwatch.md +8 -7
- data/docs/replacing-sentry.md +12 -16
- data/docs/security.md +14 -20
- data/docs/self-hosting.md +24 -22
- data/docs/testing.md +6 -14
- data/docs/troubleshooting.md +12 -46
- data/lib/generators/railwatch/install/install_generator.rb +12 -240
- data/lib/generators/railwatch/install/templates/initializer.rb.tt +10 -40
- data/lib/generators/railwatch/install/templates/post-deploy +0 -7
- data/lib/railwatch/authentication.rb +6 -5
- data/lib/railwatch/buffer.rb +9 -0
- data/lib/railwatch/configuration.rb +21 -151
- data/lib/railwatch/engine.rb +9 -118
- data/lib/railwatch/patches.rb +0 -11
- data/lib/railwatch/reporter.rb +92 -21
- data/lib/railwatch/sampler.rb +10 -2
- data/lib/railwatch/subscribers/process_info.rb +1 -2
- data/lib/railwatch/version.rb +1 -1
- data/lib/railwatch.rb +8 -64
- data/lib/tasks/railwatch_tasks.rake +3 -325
- data/llms.txt +23 -25
- metadata +3 -453
- data/app/channels/railwatch/environment_channel.rb +0 -62
- data/app/controllers/concerns/railwatch/telemetry_identity.rb +0 -25
- data/app/controllers/railwatch/alerts_controller.rb +0 -48
- data/app/controllers/railwatch/anomaly_rules_controller.rb +0 -31
- data/app/controllers/railwatch/attachments_controller.rb +0 -22
- data/app/controllers/railwatch/broadcasts_controller.rb +0 -15
- data/app/controllers/railwatch/cache_events_controller.rb +0 -30
- data/app/controllers/railwatch/commands_controller.rb +0 -16
- data/app/controllers/railwatch/comments_controller.rb +0 -11
- data/app/controllers/railwatch/dashboard_controller.rb +0 -70
- data/app/controllers/railwatch/deploys_controller.rb +0 -34
- data/app/controllers/railwatch/deprecations_controller.rb +0 -54
- data/app/controllers/railwatch/environment_scoped.rb +0 -81
- data/app/controllers/railwatch/exceptions_controller.rb +0 -52
- data/app/controllers/railwatch/executions_controller.rb +0 -13
- data/app/controllers/railwatch/issues_controller.rb +0 -258
- data/app/controllers/railwatch/jobs_controller.rb +0 -63
- data/app/controllers/railwatch/llm_calls_controller.rb +0 -124
- data/app/controllers/railwatch/logs_controller.rb +0 -40
- data/app/controllers/railwatch/mails_controller.rb +0 -19
- data/app/controllers/railwatch/notifications_controller.rb +0 -11
- data/app/controllers/railwatch/outgoing_requests_controller.rb +0 -19
- data/app/controllers/railwatch/overview_controller.rb +0 -38
- data/app/controllers/railwatch/people_controller.rb +0 -25
- data/app/controllers/railwatch/processes_controller.rb +0 -33
- data/app/controllers/railwatch/profiles_controller.rb +0 -65
- data/app/controllers/railwatch/queries_controller.rb +0 -59
- data/app/controllers/railwatch/releases_controller.rb +0 -75
- data/app/controllers/railwatch/requests_controller.rb +0 -37
- data/app/controllers/railwatch/saved_views_controller.rb +0 -53
- data/app/controllers/railwatch/scheduled_tasks_controller.rb +0 -45
- data/app/controllers/railwatch/spans_controller.rb +0 -47
- data/app/controllers/railwatch/storage_ops_controller.rb +0 -15
- data/app/controllers/railwatch/tenants_controller.rb +0 -44
- data/app/controllers/railwatch/thresholds_controller.rb +0 -47
- data/app/controllers/railwatch/traces_controller.rb +0 -72
- data/app/controllers/railwatch/transactions_controller.rb +0 -21
- data/app/controllers/railwatch/view_renders_controller.rb +0 -28
- data/app/controllers/railwatch/visits_controller.rb +0 -53
- data/app/frontend/components/app-content.tsx +0 -26
- data/app/frontend/components/app-logo-icon.tsx +0 -23
- data/app/frontend/components/app-logo.tsx +0 -14
- data/app/frontend/components/app-shell.tsx +0 -38
- data/app/frontend/components/app-sidebar-header.tsx +0 -54
- data/app/frontend/components/app-sidebar.tsx +0 -94
- data/app/frontend/components/app-wordmark.tsx +0 -29
- data/app/frontend/components/breadcrumbs.tsx +0 -46
- data/app/frontend/components/icon.tsx +0 -16
- data/app/frontend/components/input-error.tsx +0 -18
- data/app/frontend/components/nav-footer.tsx +0 -46
- data/app/frontend/components/nav-main.tsx +0 -41
- data/app/frontend/components/nav-user.tsx +0 -50
- data/app/frontend/components/railwatch/chart-hover.tsx +0 -34
- data/app/frontend/components/railwatch/chart-panel.tsx +0 -170
- data/app/frontend/components/railwatch/code.tsx +0 -28
- data/app/frontend/components/railwatch/command-palette.tsx +0 -132
- data/app/frontend/components/railwatch/copy-block.tsx +0 -52
- data/app/frontend/components/railwatch/copy-id.tsx +0 -49
- data/app/frontend/components/railwatch/cursor-load-more.tsx +0 -39
- data/app/frontend/components/railwatch/data-table.tsx +0 -195
- data/app/frontend/components/railwatch/digit-roll.tsx +0 -312
- data/app/frontend/components/railwatch/empty-state.tsx +0 -159
- data/app/frontend/components/railwatch/env-nav.tsx +0 -130
- data/app/frontend/components/railwatch/env-switcher.tsx +0 -145
- data/app/frontend/components/railwatch/filter-bar.tsx +0 -153
- data/app/frontend/components/railwatch/flamegraph.tsx +0 -324
- data/app/frontend/components/railwatch/frames.tsx +0 -295
- data/app/frontend/components/railwatch/json-viewer.tsx +0 -159
- data/app/frontend/components/railwatch/live-dot.tsx +0 -78
- data/app/frontend/components/railwatch/live-toggle.tsx +0 -76
- data/app/frontend/components/railwatch/metric-chart.tsx +0 -112
- data/app/frontend/components/railwatch/mobile-page-switcher.tsx +0 -89
- data/app/frontend/components/railwatch/onboarding.tsx +0 -91
- data/app/frontend/components/railwatch/origin-identity.tsx +0 -84
- data/app/frontend/components/railwatch/page-header.tsx +0 -42
- data/app/frontend/components/railwatch/percentile-picker.tsx +0 -18
- data/app/frontend/components/railwatch/relative-time.tsx +0 -18
- data/app/frontend/components/railwatch/saved-views.tsx +0 -232
- data/app/frontend/components/railwatch/segmented.tsx +0 -64
- data/app/frontend/components/railwatch/series-chart.tsx +0 -400
- data/app/frontend/components/railwatch/shortcuts-dialog.tsx +0 -89
- data/app/frontend/components/railwatch/signal-toast.tsx +0 -121
- data/app/frontend/components/railwatch/sort-header.tsx +0 -37
- data/app/frontend/components/railwatch/source-link.tsx +0 -57
- data/app/frontend/components/railwatch/sparkline-cell.tsx +0 -14
- data/app/frontend/components/railwatch/sql-block.tsx +0 -116
- data/app/frontend/components/railwatch/stat.tsx +0 -185
- data/app/frontend/components/railwatch/status-badge.tsx +0 -79
- data/app/frontend/components/railwatch/step-picker.tsx +0 -57
- data/app/frontend/components/railwatch/timeline.tsx +0 -405
- data/app/frontend/components/railwatch/utilisation-bar.tsx +0 -44
- data/app/frontend/components/railwatch/window-picker.tsx +0 -147
- data/app/frontend/components/ui/avatar.tsx +0 -51
- data/app/frontend/components/ui/badge.tsx +0 -46
- data/app/frontend/components/ui/breadcrumb.tsx +0 -109
- data/app/frontend/components/ui/button.tsx +0 -60
- data/app/frontend/components/ui/card.tsx +0 -92
- data/app/frontend/components/ui/chart.tsx +0 -374
- data/app/frontend/components/ui/checkbox.tsx +0 -32
- data/app/frontend/components/ui/collapsible.tsx +0 -31
- data/app/frontend/components/ui/command.tsx +0 -182
- data/app/frontend/components/ui/dialog.tsx +0 -158
- data/app/frontend/components/ui/dropdown-menu.tsx +0 -255
- data/app/frontend/components/ui/hover-card.tsx +0 -42
- data/app/frontend/components/ui/input.tsx +0 -21
- data/app/frontend/components/ui/label.tsx +0 -24
- data/app/frontend/components/ui/popover.tsx +0 -87
- data/app/frontend/components/ui/select.tsx +0 -185
- data/app/frontend/components/ui/separator.tsx +0 -28
- data/app/frontend/components/ui/sheet.tsx +0 -137
- data/app/frontend/components/ui/sidebar.tsx +0 -726
- data/app/frontend/components/ui/skeleton.tsx +0 -13
- data/app/frontend/components/ui/sonner.tsx +0 -41
- data/app/frontend/components/ui/table.tsx +0 -114
- data/app/frontend/components/ui/tabs.tsx +0 -91
- data/app/frontend/components/ui/textarea.tsx +0 -18
- data/app/frontend/components/ui/tooltip.tsx +0 -61
- data/app/frontend/components/user-info.tsx +0 -32
- data/app/frontend/components/user-menu-content.tsx +0 -94
- data/app/frontend/entrypoints/application.css +0 -244
- data/app/frontend/entrypoints/inertia.tsx +0 -92
- data/app/frontend/fonts/CommitMono-Bold.woff2 +0 -0
- data/app/frontend/fonts/CommitMono-Regular.woff2 +0 -0
- data/app/frontend/fonts/Roboto-Black.woff2 +0 -0
- data/app/frontend/fonts/Roboto-Bold.woff2 +0 -0
- data/app/frontend/fonts/Roboto-Medium.woff2 +0 -0
- data/app/frontend/fonts/Roboto-Regular.woff2 +0 -0
- data/app/frontend/hooks/use-appearance.tsx +0 -67
- data/app/frontend/hooks/use-clipboard.ts +0 -32
- data/app/frontend/hooks/use-flash.tsx +0 -23
- data/app/frontend/hooks/use-hotkeys.ts +0 -63
- data/app/frontend/hooks/use-initials.tsx +0 -15
- data/app/frontend/hooks/use-live.ts +0 -205
- data/app/frontend/hooks/use-mobile-navigation.ts +0 -8
- data/app/frontend/hooks/use-mobile.ts +0 -21
- data/app/frontend/hooks/use-percentile.ts +0 -24
- data/app/frontend/hooks/use-reduced-motion.ts +0 -25
- data/app/frontend/hooks/use-row-nav.ts +0 -106
- data/app/frontend/hooks/use-step.ts +0 -27
- data/app/frontend/hooks/use-ticker.ts +0 -12
- data/app/frontend/hooks/use-window.ts +0 -58
- data/app/frontend/layouts/app/app-sidebar-layout.tsx +0 -107
- data/app/frontend/layouts/app-layout.tsx +0 -21
- data/app/frontend/layouts/env-layout.tsx +0 -53
- data/app/frontend/layouts/persistent-layout.tsx +0 -18
- data/app/frontend/lib/cable.ts +0 -10
- data/app/frontend/lib/env-pages.ts +0 -236
- data/app/frontend/lib/execution-path.ts +0 -55
- data/app/frontend/lib/explain.ts +0 -50
- data/app/frontend/lib/filter.ts +0 -25
- data/app/frontend/lib/flamegraph.ts +0 -155
- data/app/frontend/lib/format.ts +0 -71
- data/app/frontend/lib/keyboard.ts +0 -18
- data/app/frontend/lib/mount.ts +0 -23
- data/app/frontend/lib/source-link.ts +0 -217
- data/app/frontend/lib/utils.ts +0 -6
- data/app/frontend/pages/alerts/index.tsx +0 -171
- data/app/frontend/pages/broadcasts/index.tsx +0 -171
- data/app/frontend/pages/cache_events/index.tsx +0 -127
- data/app/frontend/pages/commands/index.tsx +0 -143
- data/app/frontend/pages/deploys/index.tsx +0 -94
- data/app/frontend/pages/deploys/show.tsx +0 -309
- data/app/frontend/pages/deprecations/index.tsx +0 -176
- data/app/frontend/pages/exceptions/index.tsx +0 -271
- data/app/frontend/pages/executions/show.tsx +0 -821
- data/app/frontend/pages/issues/index.tsx +0 -399
- data/app/frontend/pages/issues/show.tsx +0 -1367
- data/app/frontend/pages/jobs/index.tsx +0 -381
- data/app/frontend/pages/jobs/klass.tsx +0 -215
- data/app/frontend/pages/llm_calls/index.tsx +0 -664
- data/app/frontend/pages/logs/index.tsx +0 -303
- data/app/frontend/pages/mails/index.tsx +0 -218
- data/app/frontend/pages/notifications/index.tsx +0 -161
- data/app/frontend/pages/outgoing_requests/index.tsx +0 -309
- data/app/frontend/pages/overview/show.tsx +0 -333
- data/app/frontend/pages/people/index.tsx +0 -102
- data/app/frontend/pages/people/show.tsx +0 -149
- data/app/frontend/pages/processes/index.tsx +0 -335
- data/app/frontend/pages/profiles/index.tsx +0 -110
- data/app/frontend/pages/profiles/show.tsx +0 -105
- data/app/frontend/pages/queries/index.tsx +0 -492
- data/app/frontend/pages/queries/show.tsx +0 -276
- data/app/frontend/pages/releases/index.tsx +0 -190
- data/app/frontend/pages/releases/release-health.tsx +0 -68
- data/app/frontend/pages/releases/release-path.ts +0 -25
- data/app/frontend/pages/releases/show.tsx +0 -262
- data/app/frontend/pages/requests/index.tsx +0 -235
- data/app/frontend/pages/requests/route.tsx +0 -159
- data/app/frontend/pages/scheduled_tasks/index.tsx +0 -220
- data/app/frontend/pages/spans/index.tsx +0 -229
- data/app/frontend/pages/spans/show.tsx +0 -220
- data/app/frontend/pages/storage_ops/index.tsx +0 -150
- data/app/frontend/pages/tenants/index.tsx +0 -236
- data/app/frontend/pages/tenants/show.tsx +0 -477
- data/app/frontend/pages/tenants/tenant-path.ts +0 -26
- data/app/frontend/pages/thresholds/index.tsx +0 -414
- data/app/frontend/pages/traces/show.tsx +0 -208
- data/app/frontend/pages/transactions/index.tsx +0 -220
- data/app/frontend/pages/view_renders/index.tsx +0 -241
- data/app/frontend/pages/visits/index.tsx +0 -321
- data/app/frontend/public/icon.png +0 -0
- data/app/frontend/public/icon.svg +0 -5
- data/app/frontend/routes/index.d.ts +0 -2136
- data/app/frontend/routes/index.js +0 -2112
- data/app/frontend/types/globals.d.ts +0 -9
- data/app/frontend/types/index.ts +0 -288
- data/app/frontend/types/vite-env.d.ts +0 -9
- data/app/helpers/railwatch/assets_helper.rb +0 -52
- data/app/jobs/railwatch/anomaly_scan_job.rb +0 -11
- data/app/jobs/railwatch/application_job.rb +0 -7
- data/app/jobs/railwatch/auto_resolve_issues_job.rb +0 -19
- data/app/jobs/railwatch/check_scheduled_tasks_job.rb +0 -113
- data/app/jobs/railwatch/detect_anomalies_job.rb +0 -171
- data/app/jobs/railwatch/detect_performance_issues_job.rb +0 -95
- data/app/jobs/railwatch/group_exceptions_job.rb +0 -91
- data/app/jobs/railwatch/optimize_telemetry_job.rb +0 -23
- data/app/jobs/railwatch/performance_scan_job.rb +0 -11
- data/app/jobs/railwatch/prune_telemetry_job.rb +0 -129
- data/app/jobs/railwatch/release_health_rollup_job.rb +0 -64
- data/app/jobs/railwatch/rollup_catchup_job.rb +0 -21
- data/app/jobs/railwatch/rollup_job.rb +0 -132
- data/app/jobs/railwatch/scheduled_task_scan_job.rb +0 -11
- data/app/models/concerns/railwatch/detection_snapshotting.rb +0 -20
- data/app/models/railwatch/alert.rb +0 -229
- data/app/models/railwatch/alert_rule.rb +0 -121
- data/app/models/railwatch/anomaly_rule.rb +0 -51
- data/app/models/railwatch/application.rb +0 -44
- data/app/models/railwatch/application_record.rb +0 -33
- data/app/models/railwatch/comment.rb +0 -36
- data/app/models/railwatch/deploy.rb +0 -67
- data/app/models/railwatch/environment.rb +0 -68
- data/app/models/railwatch/execution_presenter.rb +0 -185
- data/app/models/railwatch/filter_query.rb +0 -184
- data/app/models/railwatch/followup_receipt.rb +0 -27
- data/app/models/railwatch/ingest/batch.rb +0 -355
- data/app/models/railwatch/ingest/mapper.rb +0 -585
- data/app/models/railwatch/ingest/payload.rb +0 -96
- data/app/models/railwatch/ingest/rollup_absorber.rb +0 -170
- data/app/models/railwatch/ingest/writer.rb +0 -137
- data/app/models/railwatch/issue.rb +0 -277
- data/app/models/railwatch/issue_activity.rb +0 -23
- data/app/models/railwatch/issue_detection_presenter.rb +0 -309
- data/app/models/railwatch/issue_detection_snapshot.rb +0 -77
- data/app/models/railwatch/maintenance_task.rb +0 -53
- data/app/models/railwatch/saved_view.rb +0 -63
- data/app/models/railwatch/telemetry/aggregations.rb +0 -251
- data/app/models/railwatch/telemetry/attachment.rb +0 -31
- data/app/models/railwatch/telemetry/bounded_gzip.rb +0 -101
- data/app/models/railwatch/telemetry/broadcast.rb +0 -13
- data/app/models/railwatch/telemetry/cache_event.rb +0 -16
- data/app/models/railwatch/telemetry/child.rb +0 -53
- data/app/models/railwatch/telemetry/cursor_page.rb +0 -99
- data/app/models/railwatch/telemetry/deprecation.rb +0 -13
- data/app/models/railwatch/telemetry/enqueued_job.rb +0 -13
- data/app/models/railwatch/telemetry/exception.rb +0 -28
- data/app/models/railwatch/telemetry/execution.rb +0 -152
- data/app/models/railwatch/telemetry/export_delivery.rb +0 -32
- data/app/models/railwatch/telemetry/export_destination.rb +0 -66
- data/app/models/railwatch/telemetry/health_sample.rb +0 -72
- data/app/models/railwatch/telemetry/ingest_batch.rb +0 -31
- data/app/models/railwatch/telemetry/llm_call.rb +0 -37
- data/app/models/railwatch/telemetry/log.rb +0 -91
- data/app/models/railwatch/telemetry/mail.rb +0 -13
- data/app/models/railwatch/telemetry/n_plus_one.rb +0 -92
- data/app/models/railwatch/telemetry/notification.rb +0 -13
- data/app/models/railwatch/telemetry/outgoing_request.rb +0 -13
- data/app/models/railwatch/telemetry/person.rb +0 -50
- data/app/models/railwatch/telemetry/process.rb +0 -10
- data/app/models/railwatch/telemetry/profile.rb +0 -37
- data/app/models/railwatch/telemetry/query.rb +0 -57
- data/app/models/railwatch/telemetry/query_shape.rb +0 -45
- data/app/models/railwatch/telemetry/release_health.rb +0 -69
- data/app/models/railwatch/telemetry/rollup.rb +0 -157
- data/app/models/railwatch/telemetry/session.rb +0 -24
- data/app/models/railwatch/telemetry/span.rb +0 -19
- data/app/models/railwatch/telemetry/storage_op.rb +0 -13
- data/app/models/railwatch/telemetry/tenant.rb +0 -240
- data/app/models/railwatch/telemetry/transaction.rb +0 -13
- data/app/models/railwatch/telemetry/view_render.rb +0 -13
- data/app/models/railwatch/telemetry/visit.rb +0 -24
- data/app/models/railwatch/telemetry_record.rb +0 -107
- data/app/models/railwatch/threshold.rb +0 -66
- data/app/models/railwatch/user.rb +0 -47
- data/app/models/railwatch/viewer.rb +0 -13
- data/app/models/railwatch/window.rb +0 -66
- data/app/views/layouts/railwatch/dashboard.html.erb +0 -25
- data/db/railwatch_migrate/20260916000000_create_railwatch_tables.rb +0 -151
- data/db/railwatch_migrate/20260917000000_create_railwatch_maintenance_tasks.rb +0 -18
- data/db/railwatch_migrate/20260917120000_create_railwatch_followup_receipts.rb +0 -20
- data/db/railwatch_migrate/20260918120000_widen_host_user_ids.rb +0 -71
- data/db/railwatch_telemetry_migrate/20260903000000_enable_incremental_vacuum.rb +0 -62
- data/db/railwatch_telemetry_migrate/20260903000001_create_telemetry.rb +0 -481
- data/db/railwatch_telemetry_migrate/20260903000002_rename_tenant_to_app_tenant.rb +0 -14
- data/db/railwatch_telemetry_migrate/20260903000003_add_statement_count_to_transactions.rb +0 -9
- data/db/railwatch_telemetry_migrate/20260903000004_add_role_and_channel.rb +0 -10
- data/db/railwatch_telemetry_migrate/20260903000005_add_locals_to_exceptions.rb +0 -9
- data/db/railwatch_telemetry_migrate/20260903000006_add_spans_health_vitals_and_fts.rb +0 -82
- data/db/railwatch_telemetry_migrate/20260903000007_rename_span_attributes_to_payload.rb +0 -10
- data/db/railwatch_telemetry_migrate/20260903000008_add_profiles_and_attachments.rb +0 -60
- data/db/railwatch_telemetry_migrate/20260903000009_add_truncated_to_attachments.rb +0 -9
- data/db/railwatch_telemetry_migrate/20260903000010_create_sessions_and_release_health.rb +0 -51
- data/db/railwatch_telemetry_migrate/20260903000011_add_fingerprint_to_exceptions.rb +0 -11
- data/db/railwatch_telemetry_migrate/20260904000012_add_failed_to_broadcasts.rb +0 -10
- data/db/railwatch_telemetry_migrate/20260904010000_add_filter_cursor_indexes.rb +0 -37
- data/db/railwatch_telemetry_migrate/20260904020000_add_n_plus_ones_execution_id_index.rb +0 -11
- data/db/railwatch_telemetry_migrate/20260904120000_create_query_shapes.rb +0 -15
- data/db/railwatch_telemetry_migrate/20260906120000_add_backpressure_factor_to_ingest_batches.rb +0 -9
- data/db/railwatch_telemetry_migrate/20260907000000_rename_lantern_version_on_processes.rb +0 -10
- data/db/railwatch_telemetry_migrate/20260913000000_rename_nightrail_version_on_processes.rb +0 -9
- data/db/railwatch_telemetry_migrate/20260914000000_drop_orphan_durable_ingest_tables.rb +0 -18
- data/db/railwatch_telemetry_migrate/20260914010000_drop_orphan_durable_ingest_columns.rb +0 -25
- data/db/railwatch_telemetry_migrate/20260915000000_create_llm_calls.rb +0 -55
- data/db/railwatch_telemetry_migrate/20260915120000_add_detail_to_llm_calls.rb +0 -21
- data/db/railwatch_telemetry_migrate/20260915200000_add_explained_index_to_queries.rb +0 -16
- data/db/railwatch_telemetry_migrate/20260915210000_add_slowest_index_to_queries.rb +0 -18
- data/db/railwatch_telemetry_migrate/20260917010000_add_batch_ledger_to_ingest_batches.rb +0 -16
- data/db/railwatch_telemetry_migrate/20260919000100_create_export_queue.rb +0 -146
- data/db/railwatch_telemetry_migrate/20260922000000_add_covering_indexes_for_dashboard_aggregates.rb +0 -34
- data/db/railwatch_telemetry_migrate/20260923000000_add_people_index.rb +0 -15
- data/db/railwatch_telemetry_migrate/20260925000000_add_tenant_summary_index.rb +0 -17
- data/db/railwatch_telemetry_migrate/20261008000000_add_in_transaction_to_outgoing_requests.rb +0 -10
- data/docs/embedded.md +0 -503
- data/lib/puma/plugin/railwatch.rb +0 -215
- data/lib/railwatch/dashboard_assets.rb +0 -45
- data/lib/railwatch/embedded.rb +0 -50
- data/lib/railwatch/export/client.rb +0 -124
- data/lib/railwatch/export/lease.rb +0 -56
- data/lib/railwatch/export/outbox.rb +0 -361
- data/lib/railwatch/export/policy.rb +0 -79
- data/lib/railwatch/export/sender.rb +0 -130
- data/lib/railwatch/ingest_request_body_limit.rb +0 -10
- data/lib/railwatch/maintenance.rb +0 -194
- data/lib/railwatch/patches/migration_busy_timeout.rb +0 -73
- data/lib/railwatch/transport/local.rb +0 -78
- data/lib/railwatch/transport/socket.rb +0 -183
- data/lib/railwatch/writer.rb +0 -370
- 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-by7G3sa1.js +0 -41
- data/public/railwatch/assets/application-Cc5v_Vk9.css +0 -1
- data/public/railwatch/assets/arrow-up-DCZ7emux.js +0 -1
- data/public/railwatch/assets/braces-CykT5_5u.js +0 -1
- data/public/railwatch/assets/card-C-PPSA0g.js +0 -1
- data/public/railwatch/assets/chart-Cov79YC5.js +0 -39
- data/public/railwatch/assets/chart-hover-DdK4E-2h.js +0 -1
- data/public/railwatch/assets/chart-panel-BPaYRXJB.js +0 -1
- data/public/railwatch/assets/code-BYjC4YZ_.js +0 -1
- data/public/railwatch/assets/copy-id-LPfoj9cC.js +0 -1
- data/public/railwatch/assets/cursor-load-more-DSdhgpGl.js +0 -1
- data/public/railwatch/assets/data-table-CQ_YRWQs.js +0 -1
- data/public/railwatch/assets/env-layout-CdMWbUkm.js +0 -1
- data/public/railwatch/assets/execution-path-7MYt-Qbn.js +0 -1
- data/public/railwatch/assets/filter-bar-CrzpQ_vD.js +0 -1
- data/public/railwatch/assets/flamegraph-BIFEdTAg.js +0 -2
- data/public/railwatch/assets/format-B9SDkrWj.js +0 -1
- data/public/railwatch/assets/frames-C5t-x4f0.js +0 -1
- data/public/railwatch/assets/index--NQ7us-L.js +0 -1
- data/public/railwatch/assets/index-2kgYe_99.js +0 -1
- data/public/railwatch/assets/index-4YEPHxtO.js +0 -1
- data/public/railwatch/assets/index-AOCC1GDJ.js +0 -1
- data/public/railwatch/assets/index-BEo-u0eO.js +0 -1
- data/public/railwatch/assets/index-BJ1V3w2N.js +0 -1
- data/public/railwatch/assets/index-BPoHsBKb.js +0 -1
- data/public/railwatch/assets/index-BZzyGUM3.js +0 -1
- data/public/railwatch/assets/index-BgVq7yKL.js +0 -1
- data/public/railwatch/assets/index-BqFGEZvW.js +0 -1
- data/public/railwatch/assets/index-ByuHJ1CH.js +0 -1
- data/public/railwatch/assets/index-C2j50AL2.js +0 -1
- data/public/railwatch/assets/index-CLEdTkvW.js +0 -1
- data/public/railwatch/assets/index-CWgq8qBu.js +0 -1
- data/public/railwatch/assets/index-CXQ7_oVj.js +0 -2
- data/public/railwatch/assets/index-CmyYrjS2.js +0 -1
- data/public/railwatch/assets/index-Cn9_HjDo.js +0 -1
- data/public/railwatch/assets/index-CsMAm9UK.js +0 -1
- data/public/railwatch/assets/index-D0Kk_oGY.js +0 -1
- data/public/railwatch/assets/index-DC9ZptJh.js +0 -1
- data/public/railwatch/assets/index-DNzg7jmV.js +0 -1
- data/public/railwatch/assets/index-DQ8uJO5f.js +0 -1
- data/public/railwatch/assets/index-Deq14BxB.js +0 -1
- data/public/railwatch/assets/index-IaV3Blzp.js +0 -1
- data/public/railwatch/assets/index-TYNWcJQs.js +0 -1
- data/public/railwatch/assets/index-WonH2fcZ.js +0 -1
- data/public/railwatch/assets/index-WxWUaH_d.js +0 -1
- data/public/railwatch/assets/index-mUNrl4ky.js +0 -1
- data/public/railwatch/assets/inertia-DfNRARuA.js +0 -97
- data/public/railwatch/assets/json-viewer-DMsF5glp.js +0 -1
- data/public/railwatch/assets/klass-CmHYk5rz.js +0 -1
- data/public/railwatch/assets/origin-identity-1R3bqa3R.js +0 -1
- data/public/railwatch/assets/percentile-picker-73z5PAps.js +0 -1
- data/public/railwatch/assets/relative-time-Dsc_cCdX.js +0 -1
- data/public/railwatch/assets/release-health-BV5OaOWd.js +0 -1
- data/public/railwatch/assets/route-CWh58C7K.js +0 -1
- data/public/railwatch/assets/segmented-DB4zh-Am.js +0 -1
- data/public/railwatch/assets/select-DBKjFlOX.js +0 -1
- data/public/railwatch/assets/series-chart-BaiDG795.js +0 -1
- data/public/railwatch/assets/show-B4ICCFGh.js +0 -1
- data/public/railwatch/assets/show-B5lOU9Gv.js +0 -1
- data/public/railwatch/assets/show-BYPB74t4.js +0 -2
- data/public/railwatch/assets/show-C7A0m2M8.js +0 -1
- data/public/railwatch/assets/show-DT5X1zqE.js +0 -1
- data/public/railwatch/assets/show-D_NBdXsn.js +0 -2
- data/public/railwatch/assets/show-DgPCnRg8.js +0 -1
- data/public/railwatch/assets/show-Yl69HF0x.js +0 -1
- data/public/railwatch/assets/show-_QxuUQCm.js +0 -3
- data/public/railwatch/assets/show-g__jnN36.js +0 -1
- data/public/railwatch/assets/show-l4ztfjl9.js +0 -1
- data/public/railwatch/assets/sort-header-BcG8ulEH.js +0 -1
- data/public/railwatch/assets/source-link-B2183i2-.js +0 -1
- data/public/railwatch/assets/sparkline-cell-B47ipscB.js +0 -1
- data/public/railwatch/assets/stat-DT2hhsqS.js +0 -1
- data/public/railwatch/assets/status-badge-CmbRKlY7.js +0 -1
- data/public/railwatch/assets/tenant-path-C4rNGauE.js +0 -1
- data/public/railwatch/assets/use-clipboard-oTNeP4ax.js +0 -1
- data/public/railwatch/assets/use-live-CLZZj245.js +0 -1
- data/public/railwatch/icon.png +0 -0
- data/public/railwatch/icon.svg +0 -5
- data/public/railwatch/manifest.json +0 -1245
- data/public/railwatch/rails-vite.json +0 -1
- /data/{app/frontend/lib → lib/generators/railwatch/install/templates}/railwatch.ts +0 -0
|
@@ -1,95 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Railwatch
|
|
4
|
-
# Evaluates every threshold for an environment against the last window of
|
|
5
|
-
# rollups and opens performance issues. Runs on a schedule (recurring.yml).
|
|
6
|
-
class DetectPerformanceIssuesJob < ApplicationJob
|
|
7
|
-
include DetectionSnapshotting
|
|
8
|
-
|
|
9
|
-
queue_as :default
|
|
10
|
-
|
|
11
|
-
MAX_GROUPS_PER_RULE = 200
|
|
12
|
-
MAX_ROLLUP_ROWS_PER_RULE = MAX_GROUPS_PER_RULE * 25
|
|
13
|
-
|
|
14
|
-
TYPE_FOR = { "requests" => "request", "jobs" => "job_attempt", "commands" => "command", "queries" => "query",
|
|
15
|
-
"scheduled_tasks" => "scheduled_task", "outgoing_requests" => "outgoing_request",
|
|
16
|
-
"llm_calls" => "llm_call", "llm_tools" => "llm_tool" }.freeze
|
|
17
|
-
|
|
18
|
-
def perform(environment)
|
|
19
|
-
now = Time.current
|
|
20
|
-
environment.thresholds.find_each do |threshold|
|
|
21
|
-
from = now - threshold.window_minutes.minutes
|
|
22
|
-
type = TYPE_FOR.fetch(threshold.target_kind)
|
|
23
|
-
groups = environment.with_telemetry do
|
|
24
|
-
scope = Telemetry::Rollup.for_type(type).between(from, now)
|
|
25
|
-
scope = scope.where(name: threshold.target) unless threshold.target == "*"
|
|
26
|
-
bounded_groups(scope, threshold)
|
|
27
|
-
end
|
|
28
|
-
groups.each do |group_hash, (name, summary)|
|
|
29
|
-
value = value_for(threshold.metric, summary)
|
|
30
|
-
next if value.nil? || value <= threshold.limit
|
|
31
|
-
open_issue(environment, threshold, group_hash, name, value, from, now)
|
|
32
|
-
end
|
|
33
|
-
end
|
|
34
|
-
end
|
|
35
|
-
|
|
36
|
-
private
|
|
37
|
-
|
|
38
|
-
def bounded_groups(scope, threshold)
|
|
39
|
-
group_hashes = scope.reorder(nil).group(:group_hash).order(Arel.sql("SUM(count) DESC"), :group_hash)
|
|
40
|
-
.limit(MAX_GROUPS_PER_RULE + 1).pluck(:group_hash)
|
|
41
|
-
if group_hashes.length > MAX_GROUPS_PER_RULE
|
|
42
|
-
Rails.logger.warn("threshold detector evaluated only the #{MAX_GROUPS_PER_RULE} busiest groups threshold_id=#{threshold.id}")
|
|
43
|
-
group_hashes.pop
|
|
44
|
-
end
|
|
45
|
-
rows = scope.where(group_hash: group_hashes).order(:bucket, :group_hash).limit(MAX_ROLLUP_ROWS_PER_RULE + 1).to_a
|
|
46
|
-
if rows.length > MAX_ROLLUP_ROWS_PER_RULE
|
|
47
|
-
Rails.logger.error("threshold detector skipped threshold_id=#{threshold.id}: more than #{MAX_ROLLUP_ROWS_PER_RULE} rollups")
|
|
48
|
-
return {}
|
|
49
|
-
end
|
|
50
|
-
|
|
51
|
-
rows.group_by(&:group_hash)
|
|
52
|
-
.transform_values { |group_rows| [ group_rows.first.name, Telemetry::Rollup.summarize(group_rows) ] }
|
|
53
|
-
end
|
|
54
|
-
|
|
55
|
-
NANOS_PER_DOLLAR = 1_000_000_000.0
|
|
56
|
-
|
|
57
|
-
def value_for(metric, s)
|
|
58
|
-
extra = s[:extra] || {}
|
|
59
|
-
case metric
|
|
60
|
-
when "p95" then s[:p95] / 1000.0
|
|
61
|
-
when "max" then s[:max] / 1000.0
|
|
62
|
-
when "avg" then s[:avg] / 1000.0
|
|
63
|
-
when "error_rate", "failure_rate" then s[:count].zero? ? nil : (s[:errors] * 100.0 / s[:count])
|
|
64
|
-
# Money and tokens are sums over the window, not percentiles of it.
|
|
65
|
-
when "spend" then extra["cost_nanos"].to_i / NANOS_PER_DOLLAR
|
|
66
|
-
when "tokens" then extra["input_tokens"].to_i + extra["output_tokens"].to_i
|
|
67
|
-
# A cut-off answer is a successful call, so it is invisible to
|
|
68
|
-
# error_rate. This is the only way to be told about it.
|
|
69
|
-
when "truncation_rate" then s[:count].zero? ? nil : (extra["truncated"].to_i * 100.0 / s[:count])
|
|
70
|
-
end
|
|
71
|
-
end
|
|
72
|
-
|
|
73
|
-
def open_issue(environment, threshold, group_hash, name, value, from, now)
|
|
74
|
-
issue, outcome = Issue.record_occurrence!(
|
|
75
|
-
environment: environment, group_hash: "perf:#{threshold.id}:#{group_hash}", kind: "performance",
|
|
76
|
-
title: "#{name} exceeded #{threshold.metric} #{threshold.format_value(threshold.limit)} (#{threshold.format_value(value.round(2))})",
|
|
77
|
-
culprit: name, occurred_at: now, deploy: nil, user_ref: nil,
|
|
78
|
-
sample: { threshold_id: threshold.id, value: value.round(2), metric: threshold.metric, limit: threshold.limit })
|
|
79
|
-
carry_detection(issue, outcome) do
|
|
80
|
-
IssueDetectionSnapshot.capture(
|
|
81
|
-
environment: environment, kind: "performance", telemetry_type: TYPE_FOR.fetch(threshold.target_kind),
|
|
82
|
-
telemetry_group_hash: group_hash, target: name, metric: threshold.metric, from: from, to: now,
|
|
83
|
-
value: value.round(2), limit: threshold.limit,
|
|
84
|
-
rule: { type: "threshold", id: threshold.id, target_kind: threshold.target_kind, target: threshold.target }
|
|
85
|
-
)
|
|
86
|
-
end
|
|
87
|
-
return unless outcome == :new || outcome == :regressed
|
|
88
|
-
payload = { issue_key: issue.key, title: issue.title, environment: environment.name }
|
|
89
|
-
environment.application.alert_rules.where(event: "threshold").find_each do |rule|
|
|
90
|
-
next unless rule.matches?(issue: issue, payload: payload)
|
|
91
|
-
rule.fire!(event: "threshold", issue: issue, payload: payload)
|
|
92
|
-
end
|
|
93
|
-
end
|
|
94
|
-
end
|
|
95
|
-
end
|
|
@@ -1,91 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Railwatch
|
|
4
|
-
# Turns newly ingested exception rows into Issues (create or bump), and
|
|
5
|
-
# fires new / regressed alerts.
|
|
6
|
-
class GroupExceptionsJob < ApplicationJob
|
|
7
|
-
queue_as :default
|
|
8
|
-
|
|
9
|
-
# A browser whose in-flight request died while kamal-proxy swapped
|
|
10
|
-
# containers reports a network error a few seconds after the deploy
|
|
11
|
-
# marker. That is the deploy working, not the app failing, and it opened
|
|
12
|
-
# (then regressed) an issue on every deploy today. The exception rows are
|
|
13
|
-
# still recorded; they just do not become an issue when they land inside
|
|
14
|
-
# this window around a deploy. Same idea as CheckScheduledTasksJob's
|
|
15
|
-
# DEPLOY_GRACE for missed runs.
|
|
16
|
-
DEPLOY_GRACE = 3.minutes
|
|
17
|
-
DEPLOY_NETWORK_ERRORS = %w[HttpNetworkError AxiosError InertiaException NetworkError TypeError:NetworkError].freeze
|
|
18
|
-
|
|
19
|
-
# batch_id: the embedded ledger's id for the batch these rows came from.
|
|
20
|
-
# With one, each group's occurrence count is committed together with a
|
|
21
|
-
# FollowupReceipt for (batch, group), so running this again for the same
|
|
22
|
-
# batch (a crash after the count but before the ledger was cleared, or
|
|
23
|
-
# the inline drain racing the maintenance drain) counts nothing twice.
|
|
24
|
-
# The hosted platform enqueues this once per HTTP batch and passes none.
|
|
25
|
-
def perform(environment, exception_ids, batch_id: nil)
|
|
26
|
-
rows = environment.with_telemetry { Telemetry::Exception.where(id: exception_ids).to_a }
|
|
27
|
-
rows = rows.reject { |row| deploy_swap_noise?(environment, row) }
|
|
28
|
-
rows.group_by(&:group_hash).each do |group_hash, group|
|
|
29
|
-
issue = outcome = nil
|
|
30
|
-
Issue.transaction do
|
|
31
|
-
next if batch_id && !FollowupReceipt.claim!(batch_id: batch_id, group_hash: group_hash)
|
|
32
|
-
|
|
33
|
-
latest = group.max_by(&:occurred_at)
|
|
34
|
-
issue, outcome = Issue.record_occurrence!(
|
|
35
|
-
environment: environment, group_hash: group_hash, kind: "exception",
|
|
36
|
-
title: "#{latest.class_name}: #{latest.message.to_s.first(200)}",
|
|
37
|
-
culprit: [ latest.file, latest.line ].compact.join(":").presence,
|
|
38
|
-
occurred_at: latest.occurred_at, deploy: latest.deploy, user_ref: latest.user_ref, source: latest.source,
|
|
39
|
-
sample: { exception_id: latest.id, handled: latest.handled, execution_id: latest.execution_id,
|
|
40
|
-
execution_preview: latest.execution_preview, deploy: latest.deploy,
|
|
41
|
-
fingerprint: latest.fingerprint, fingerprint_source: latest.fingerprint_source })
|
|
42
|
-
issue.increment!(:occurrences, group.size - 1) if group.size > 1
|
|
43
|
-
end
|
|
44
|
-
next unless issue
|
|
45
|
-
|
|
46
|
-
update_affected_users(environment, issue) if affected_users_due?(issue, outcome)
|
|
47
|
-
alert(issue, outcome)
|
|
48
|
-
end
|
|
49
|
-
end
|
|
50
|
-
|
|
51
|
-
# The affected-user count is a DISTINCT over every retained occurrence of
|
|
52
|
-
# the group, so on a busy issue it grows with the retention window and
|
|
53
|
-
# used to run once per batch per group. Once per window per issue, and
|
|
54
|
-
# always for a new issue, keeps the number fresh at a bounded cost.
|
|
55
|
-
AFFECTED_USERS_WINDOW = 5.minutes
|
|
56
|
-
AFFECTED_USERS_AT = Concurrent::Map.new
|
|
57
|
-
|
|
58
|
-
private
|
|
59
|
-
|
|
60
|
-
def affected_users_due?(issue, outcome)
|
|
61
|
-
now = Time.current
|
|
62
|
-
return AFFECTED_USERS_AT[issue.id] = now if outcome == :new
|
|
63
|
-
|
|
64
|
-
last = AFFECTED_USERS_AT[issue.id]
|
|
65
|
-
return false if last && now - last < AFFECTED_USERS_WINDOW
|
|
66
|
-
|
|
67
|
-
AFFECTED_USERS_AT[issue.id] = now
|
|
68
|
-
end
|
|
69
|
-
|
|
70
|
-
def deploy_swap_noise?(environment, row)
|
|
71
|
-
return false unless row.source == "browser" && DEPLOY_NETWORK_ERRORS.include?(row.class_name)
|
|
72
|
-
|
|
73
|
-
last_deploy_at = environment.deploys.maximum(:deployed_at) or return false
|
|
74
|
-
row.occurred_at.between?(last_deploy_at - DEPLOY_GRACE, last_deploy_at + DEPLOY_GRACE)
|
|
75
|
-
end
|
|
76
|
-
|
|
77
|
-
def update_affected_users(environment, issue)
|
|
78
|
-
count = environment.with_telemetry { Telemetry::Exception.where(group_hash: issue.group_hash).where.not(user_ref: nil).distinct.count(:user_ref) }
|
|
79
|
-
issue.update_columns(affected_users: count)
|
|
80
|
-
end
|
|
81
|
-
|
|
82
|
-
def alert(issue, outcome)
|
|
83
|
-
event = { new: "new_issue", regressed: "regressed_issue" }[outcome] or return
|
|
84
|
-
payload = { issue_key: issue.key, title: issue.title, environment: issue.environment.name }
|
|
85
|
-
issue.application.alert_rules.where(event: event).find_each do |rule|
|
|
86
|
-
next unless rule.matches?(issue: issue, payload: payload)
|
|
87
|
-
rule.fire!(event: event, issue: issue, payload: payload)
|
|
88
|
-
end
|
|
89
|
-
end
|
|
90
|
-
end
|
|
91
|
-
end
|
|
@@ -1,23 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Railwatch
|
|
4
|
-
# Refreshes SQLite's planner statistics for each environment's telemetry
|
|
5
|
-
# database. Nothing else ever runs ANALYZE on these files, so the planner
|
|
6
|
-
# has been choosing index shapes blind on tables that grow by hundreds of
|
|
7
|
-
# thousands of rows a day. PRAGMA optimize only re-analyzes tables whose
|
|
8
|
-
# stats look stale, so it is cheap to run daily; analysis_limit bounds the
|
|
9
|
-
# rows it samples per index.
|
|
10
|
-
class OptimizeTelemetryJob < ApplicationJob
|
|
11
|
-
queue_as :maintenance
|
|
12
|
-
|
|
13
|
-
def perform(environment = nil)
|
|
14
|
-
return [ Environment.current ].each { |env| self.class.perform_later(env) } if environment.nil?
|
|
15
|
-
|
|
16
|
-
environment.with_telemetry do
|
|
17
|
-
connection = TelemetryRecord.connection
|
|
18
|
-
connection.execute("PRAGMA analysis_limit = 1000")
|
|
19
|
-
connection.execute("PRAGMA optimize")
|
|
20
|
-
end
|
|
21
|
-
end
|
|
22
|
-
end
|
|
23
|
-
end
|
|
@@ -1,129 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Railwatch
|
|
4
|
-
# Deletes raw rows past the account's retention window. Rollups are kept
|
|
5
|
-
# for 13 months so long-range charts keep working after raw rows are gone.
|
|
6
|
-
# Telemetry::Session is monitored-application telemetry; dashboard login
|
|
7
|
-
# sessions live in the primary database and are deliberately not touched.
|
|
8
|
-
class PruneTelemetryJob < ApplicationJob
|
|
9
|
-
queue_as :maintenance
|
|
10
|
-
|
|
11
|
-
AGGREGATE_RETENTION = 13.months
|
|
12
|
-
# Sessions have never been pruned, so the first run of this job would walk
|
|
13
|
-
# every hour an environment has ever recorded. Delete a bounded number of
|
|
14
|
-
# hourly buckets per run and let the nightly schedule drain the backlog.
|
|
15
|
-
SESSION_HOURS_PER_RUN = 48
|
|
16
|
-
BATCH = 5_000
|
|
17
|
-
# Per raw table per run. A backlog past this (a retention change, a
|
|
18
|
-
# restore of an old file) drains over successive runs rather than in one
|
|
19
|
-
# that holds SQLite's write lock for as long as it takes. In the embedded
|
|
20
|
-
# writer that long hold would look like a wedge and end the process.
|
|
21
|
-
MAX_BATCHES_PER_TABLE = 40
|
|
22
|
-
|
|
23
|
-
# Reclaim, bounded the same way the deletes are. Deleting rows only moves
|
|
24
|
-
# their pages to the freelist; PRAGMA incremental_vacuum is what hands
|
|
25
|
-
# them back to the filesystem, and it is only possible at all on a
|
|
26
|
-
# database in auto_vacuum=incremental (EnableIncrementalVacuum, or
|
|
27
|
-
# `bin/rails railwatch:vacuum` for a database that predates it).
|
|
28
|
-
# Measured at 80k-190k pages/s warm, so a full run is well under a second
|
|
29
|
-
# there; the slicing is for the cold, large file, where each slice takes
|
|
30
|
-
# and releases the write lock instead of holding it throughout.
|
|
31
|
-
VACUUM_PAGES_PER_SLICE = 2_000
|
|
32
|
-
# 50k pages, ~200MB at SQLite's 4KB default. A few thousand pages a night
|
|
33
|
-
# would never keep up with a night's deletes on a busy app, and the file
|
|
34
|
-
# would go on growing with the freelist; a backlog past this one still
|
|
35
|
-
# drains over successive nightly runs.
|
|
36
|
-
VACUUM_SLICES = 25
|
|
37
|
-
|
|
38
|
-
RAW = [ Telemetry::Execution, Telemetry::Query, Telemetry::Exception, Telemetry::CacheEvent, Telemetry::Mail,
|
|
39
|
-
Telemetry::Broadcast, Telemetry::Notification, Telemetry::OutgoingRequest, Telemetry::StorageOp,
|
|
40
|
-
Telemetry::ViewRender, Telemetry::Log, Telemetry::EnqueuedJob, Telemetry::Transaction,
|
|
41
|
-
Telemetry::NPlusOne, Telemetry::Deprecation, Telemetry::Visit, Telemetry::Span,
|
|
42
|
-
Telemetry::LlmCall,
|
|
43
|
-
Telemetry::Profile, Telemetry::Attachment ].freeze
|
|
44
|
-
|
|
45
|
-
# checkpoint: the WAL checkpoint mode run at the end. TRUNCATE (the
|
|
46
|
-
# default, for a dedicated worker) hands the space back to the filesystem
|
|
47
|
-
# but blocks every reader and writer while it does; PASSIVE checkpoints
|
|
48
|
-
# what it can without waiting on anyone, which is what a prune running
|
|
49
|
-
# inside a Puma worker (Railwatch::Maintenance) must use.
|
|
50
|
-
def perform(environment = nil, checkpoint: "TRUNCATE")
|
|
51
|
-
return [ Environment.current ].each { |env| self.class.perform_later(env) } if environment.nil?
|
|
52
|
-
|
|
53
|
-
cutoff = environment.retention_days.days.ago
|
|
54
|
-
aggregate_cutoff = AGGREGATE_RETENTION.ago
|
|
55
|
-
environment.with_telemetry do
|
|
56
|
-
unindex_logs(cutoff) if Telemetry::Log.fts_available?
|
|
57
|
-
RAW.each { |klass| prune(klass, cutoff) }
|
|
58
|
-
# NOT EXISTS rather than NOT IN: queries.group_hash is nullable, and one
|
|
59
|
-
# NULL in a NOT IN subquery makes it match nothing. A few hundred shapes.
|
|
60
|
-
Telemetry::QueryShape.where("NOT EXISTS (SELECT 1 FROM queries WHERE queries.group_hash = query_shapes.group_hash)").delete_all
|
|
61
|
-
prune_sessions(cutoff)
|
|
62
|
-
Telemetry::Rollup.where(bucket: ...aggregate_cutoff).delete_all
|
|
63
|
-
Telemetry::ReleaseHealth.where(bucket: ...aggregate_cutoff).in_batches(of: 5_000).delete_all
|
|
64
|
-
Telemetry::Person.where(last_seen_at: ...cutoff).in_batches(of: 5_000).delete_all
|
|
65
|
-
Telemetry::IngestBatch.where(received_at: ...cutoff).delete_all
|
|
66
|
-
Telemetry::Process.where(booted_at: ...cutoff).delete_all
|
|
67
|
-
Telemetry::HealthSample.where(sampled_at: ...cutoff).delete_all
|
|
68
|
-
# Before the checkpoint, not after: incremental_vacuum truncates the
|
|
69
|
-
# database file, and in WAL mode that truncation only reaches the file
|
|
70
|
-
# on disk once it is checkpointed.
|
|
71
|
-
TelemetryRecord.reclaim_freelist!(slice: VACUUM_PAGES_PER_SLICE, slices: VACUUM_SLICES)
|
|
72
|
-
TelemetryRecord.connection.execute("PRAGMA wal_checkpoint(#{checkpoint})") if TelemetryRecord.sqlite?
|
|
73
|
-
end
|
|
74
|
-
end
|
|
75
|
-
|
|
76
|
-
private
|
|
77
|
-
|
|
78
|
-
# in_batches walks a table by primary key: its probe is "WHERE occurred_at
|
|
79
|
-
# < ? ORDER BY id LIMIT n", which no index serves, so SQLite scanned the
|
|
80
|
-
# whole table by rowid to find nothing to delete -- 42 seconds on the
|
|
81
|
-
# platform's own 12M-row queries table, every night, for an empty result.
|
|
82
|
-
# Ordered by occurred_at the same probe is one seek on any index led by
|
|
83
|
-
# that column, and the first page of expired rows is exactly the oldest
|
|
84
|
-
# ones. Tables without such an index still scan, but they are the small
|
|
85
|
-
# ones.
|
|
86
|
-
def prune(klass, cutoff)
|
|
87
|
-
MAX_BATCHES_PER_TABLE.times do
|
|
88
|
-
# Ordered by (occurred_at, id), not occurred_at alone: rows that tie
|
|
89
|
-
# on the timestamp at the limit boundary would otherwise be a
|
|
90
|
-
# different set here than in unindex_logs above, which leaves stale
|
|
91
|
-
# FTS postings behind and withdraws postings for logs that are staying.
|
|
92
|
-
ids = klass.where(occurred_at: ...cutoff).order(:occurred_at, :id).limit(BATCH).pluck(:id)
|
|
93
|
-
break if ids.empty?
|
|
94
|
-
klass.where(id: ids).delete_all
|
|
95
|
-
break if ids.size < BATCH
|
|
96
|
-
end
|
|
97
|
-
end
|
|
98
|
-
|
|
99
|
-
# Sessions are the raw input to the hourly release_health aggregate, so they
|
|
100
|
-
# are deleted a whole hour at a time. Flooring the cutoff to the start of its
|
|
101
|
-
# hour leaves the partial hour straddling the boundary in place: deleting
|
|
102
|
-
# only its expired prefix would let the next rollup rebuild that hour from
|
|
103
|
-
# the surviving tail and silently shrink an already correct aggregate.
|
|
104
|
-
def prune_sessions(cutoff)
|
|
105
|
-
expired = Telemetry::Session.where(occurred_at: ...cutoff.utc.beginning_of_hour)
|
|
106
|
-
SESSION_HOURS_PER_RUN.times do
|
|
107
|
-
oldest = expired.minimum(:occurred_at)
|
|
108
|
-
break if oldest.nil?
|
|
109
|
-
|
|
110
|
-
hour = oldest.utc.beginning_of_hour
|
|
111
|
-
expired.where(occurred_at: hour...(hour + 1.hour)).in_batches(of: 5_000).delete_all
|
|
112
|
-
end
|
|
113
|
-
end
|
|
114
|
-
|
|
115
|
-
# logs_fts is an external-content index with no triggers, so the postings
|
|
116
|
-
# for a log line have to be withdrawn while its row (and message) is still
|
|
117
|
-
# there. Deleting the rows first would leave the index permanently out of
|
|
118
|
-
# sync -- searches would keep returning rowids that no longer exist.
|
|
119
|
-
# Bounded the same way as the rows it precedes: the postings for at most
|
|
120
|
-
# MAX_BATCHES_PER_TABLE * BATCH expired lines are withdrawn per run, which
|
|
121
|
-
# is exactly the set prune(Telemetry::Log) will delete this run.
|
|
122
|
-
def unindex_logs(cutoff)
|
|
123
|
-
Telemetry::Log.connection.execute(Telemetry::Log.sanitize_sql_array([
|
|
124
|
-
"INSERT INTO logs_fts(logs_fts, rowid, message) SELECT 'delete', id, message FROM logs " \
|
|
125
|
-
"WHERE occurred_at < ? ORDER BY occurred_at, id LIMIT ?", cutoff, MAX_BATCHES_PER_TABLE * BATCH
|
|
126
|
-
]))
|
|
127
|
-
end
|
|
128
|
-
end
|
|
129
|
-
end
|
|
@@ -1,64 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Railwatch
|
|
4
|
-
# Recomputes one hourly release_health bucket from raw `sessions` rows.
|
|
5
|
-
# Idempotent, and debounced by the same Solid Queue concurrency control
|
|
6
|
-
# RollupJob uses: while one recompute of a bucket is queued or running,
|
|
7
|
-
# further enqueues for it are discarded rather than piling up.
|
|
8
|
-
#
|
|
9
|
-
# The gem re-sends a live session every flush interval, so the same
|
|
10
|
-
# session_id lands in a bucket many times. Each id collapses to the worst
|
|
11
|
-
# status it reached (crashed beats errored beats ok beats started) and its
|
|
12
|
-
# longest reported duration, which is what makes "sessions" a session count
|
|
13
|
-
# rather than a record count.
|
|
14
|
-
class ReleaseHealthRollupJob < ApplicationJob
|
|
15
|
-
queue_as :rollups
|
|
16
|
-
limits_concurrency to: 1, key: ->(environment, bucket) { "release_health:#{environment.id}:#{bucket.to_i}" }, duration: 10.minutes, on_conflict: :discard
|
|
17
|
-
|
|
18
|
-
RANKS = { "started" => 0, "ok" => 1, "errored" => 2, "crashed" => 3 }.freeze
|
|
19
|
-
CRASHED = RANKS["crashed"]
|
|
20
|
-
ERRORED = RANKS["errored"]
|
|
21
|
-
|
|
22
|
-
def perform(environment, bucket)
|
|
23
|
-
bucket = bucket.utc.beginning_of_hour
|
|
24
|
-
# PruneTelemetryJob deletes raw sessions in whole hours below this floor.
|
|
25
|
-
# A rollup enqueued before the prune can run after it, and rebuilding a
|
|
26
|
-
# pruned hour would replace a complete aggregate with an empty one.
|
|
27
|
-
return if bucket < environment.retention_days.days.ago.utc.beginning_of_hour
|
|
28
|
-
|
|
29
|
-
environment.with_telemetry do
|
|
30
|
-
rows = Telemetry::Session.where(occurred_at: bucket...(bucket + 1.hour))
|
|
31
|
-
.where.not(deploy: nil).pluck(:deploy, :session_id, :status, :duration, :user_ref)
|
|
32
|
-
aggregates = rows.group_by(&:first).map { |deploy, group| aggregate(deploy, bucket, collapse(group)) }
|
|
33
|
-
Telemetry::ReleaseHealth.transaction do
|
|
34
|
-
Telemetry::ReleaseHealth.where(bucket: bucket).delete_all
|
|
35
|
-
Telemetry::ReleaseHealth.insert_all(aggregates) if aggregates.any?
|
|
36
|
-
end
|
|
37
|
-
end
|
|
38
|
-
end
|
|
39
|
-
|
|
40
|
-
private
|
|
41
|
-
|
|
42
|
-
# session_id => the worst status, longest duration, and first user seen for
|
|
43
|
-
# it in this bucket.
|
|
44
|
-
def collapse(rows)
|
|
45
|
-
rows.each_with_object({}) do |(_deploy, session_id, status, duration, user_ref), sessions|
|
|
46
|
-
session = sessions[session_id] ||= { rank: 0, duration: nil, user: nil }
|
|
47
|
-
session[:rank] = [ session[:rank], RANKS.fetch(status.to_s, 0) ].max
|
|
48
|
-
session[:duration] = [ session[:duration] || 0, duration ].max if duration
|
|
49
|
-
session[:user] ||= user_ref
|
|
50
|
-
end
|
|
51
|
-
end
|
|
52
|
-
|
|
53
|
-
def aggregate(deploy, bucket, sessions)
|
|
54
|
-
durations = sessions.values.filter_map { |s| s[:duration] }
|
|
55
|
-
users = sessions.values.filter_map { |s| s[:user] }.uniq
|
|
56
|
-
crashed_users = sessions.values.select { |s| s[:rank] == CRASHED }.filter_map { |s| s[:user] }.uniq
|
|
57
|
-
{ deploy: deploy, bucket: bucket, sessions: sessions.size,
|
|
58
|
-
sessions_errored: sessions.values.count { |s| s[:rank] == ERRORED },
|
|
59
|
-
sessions_crashed: sessions.values.count { |s| s[:rank] == CRASHED },
|
|
60
|
-
users: users.size, users_crashed: crashed_users.size,
|
|
61
|
-
duration_sum: durations.sum, duration_count: durations.size }
|
|
62
|
-
end
|
|
63
|
-
end
|
|
64
|
-
end
|
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Railwatch
|
|
4
|
-
# Recomputes the current and previous hour so rollups are never more than a
|
|
5
|
-
# schedule tick stale, whatever happened to the per-batch RollupJob enqueues
|
|
6
|
-
# (debounced in the web process, and their concurrency semaphore can outlive
|
|
7
|
-
# a worker restart). The platform iterates every active environment; an
|
|
8
|
-
# embedded install has one.
|
|
9
|
-
class RollupCatchupJob < ApplicationJob
|
|
10
|
-
queue_as :rollups
|
|
11
|
-
|
|
12
|
-
def perform
|
|
13
|
-
env = Environment.current
|
|
14
|
-
now = Time.current
|
|
15
|
-
[ now.beginning_of_hour, (now - 1.hour).beginning_of_hour ].each do |bucket|
|
|
16
|
-
RollupJob.perform_now(env, bucket)
|
|
17
|
-
ReleaseHealthRollupJob.perform_now(env, bucket)
|
|
18
|
-
end
|
|
19
|
-
end
|
|
20
|
-
end
|
|
21
|
-
end
|
|
@@ -1,132 +0,0 @@
|
|
|
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
|
-
# A call that failed before the provider answered used no tokens,
|
|
122
|
-
# so there is nothing to price; only an answered call can be unpriced.
|
|
123
|
-
unpriced: group.count { |r| r.cost_nanos.nil? && r.status != "failed" },
|
|
124
|
-
# A cut-off answer is not an error and will never show in the error
|
|
125
|
-
# rate, so it needs counting on its own or it stays invisible.
|
|
126
|
-
truncated: group.count { |r| r.finish_reason == "max_tokens" },
|
|
127
|
-
with_attachments: group.count { |r| r.attachments.to_i.positive? } }
|
|
128
|
-
else {}
|
|
129
|
-
end
|
|
130
|
-
end
|
|
131
|
-
end
|
|
132
|
-
end
|
|
@@ -1,20 +0,0 @@
|
|
|
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
|