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,152 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Railwatch
|
|
4
|
-
module Telemetry
|
|
5
|
-
# A request, job attempt, scheduled task, command, or channel action. The parent of
|
|
6
|
-
# every other telemetry row via execution_id.
|
|
7
|
-
class Execution < TelemetryRecord
|
|
8
|
-
KINDS = %w[request job_attempt scheduled_task command channel_action].freeze
|
|
9
|
-
|
|
10
|
-
scope :of_kind, ->(kind) { where(kind: kind) }
|
|
11
|
-
scope :requests, -> { where(kind: "request") }
|
|
12
|
-
scope :jobs, -> { where(kind: "job_attempt") }
|
|
13
|
-
scope :scheduled, -> { where(kind: "scheduled_task") }
|
|
14
|
-
scope :commands, -> { where(kind: "command") }
|
|
15
|
-
scope :channels, -> { where(kind: "channel_action") }
|
|
16
|
-
scope :recent, -> { order(occurred_at: :desc) }
|
|
17
|
-
scope :between, ->(from, to) { where(occurred_at: from..to) }
|
|
18
|
-
scope :failed, -> { where("status >= 500 OR outcome = 'failed'") }
|
|
19
|
-
|
|
20
|
-
# Executions of `kind` in [from, to] whose name contains `text`, or
|
|
21
|
-
# (with previews: true) whose exception preview does. A LIKE on the rows
|
|
22
|
-
# themselves reads every execution in the window until it has a page:
|
|
23
|
-
# 32 s for a week of the platform's own requests when the text is rare.
|
|
24
|
-
# Names come from a few hundred groups, so the text is matched against
|
|
25
|
-
# the window's rollups and the rows are fetched by group_hash. Rollups
|
|
26
|
-
# trail ingest by up to a minute, so the last FRESH_NAMES are matched on
|
|
27
|
-
# the rows themselves. Only failures carry a preview, and a partial
|
|
28
|
-
# index holds just those rows.
|
|
29
|
-
#
|
|
30
|
-
# Each branch names its index and the outer lookup is by rowid, so the
|
|
31
|
-
# plan does not depend on planner statistics (an embedded install's are
|
|
32
|
-
# sampled, and sampled statistics make kind look selective). When the
|
|
33
|
-
# text is common the old walk is the fast one -- a page of matches
|
|
34
|
-
# turns up in the first few thousand rows -- so a text the rollups say
|
|
35
|
-
# matches DENSE_MATCHES rows or more keeps it. On a copy of the
|
|
36
|
-
# platform's own tenant, a week of job attempts: a text matching
|
|
37
|
-
# nothing took 657 ms walking and 27 ms here; one matching 32,766 took
|
|
38
|
-
# 11 ms walking and 215 ms here.
|
|
39
|
-
FRESH_NAMES = 5.minutes
|
|
40
|
-
DENSE_MATCHES = 1_000
|
|
41
|
-
|
|
42
|
-
# `range` is used as given, so an exclusive or empty one (FilterQuery's
|
|
43
|
-
# after:/before: can narrow a window to nothing) stays that way.
|
|
44
|
-
def self.named_like(kind, text, range, previews: false)
|
|
45
|
-
from, to = range.begin, range.end
|
|
46
|
-
pattern = "%#{sanitize_sql_like(text)}%"
|
|
47
|
-
window = where(kind: kind, occurred_at: range)
|
|
48
|
-
groups = Rollup.for_type(kind).between(from, to).where("name LIKE ? ESCAPE '\\'", pattern)
|
|
49
|
-
if !TelemetryRecord.sqlite? || groups.sum(:count) >= DENSE_MATCHES
|
|
50
|
-
like = previews ? "name LIKE :pattern ESCAPE '\\' OR exception_preview LIKE :pattern ESCAPE '\\'" : "name LIKE :pattern ESCAPE '\\'"
|
|
51
|
-
return window.where(like, pattern: pattern)
|
|
52
|
-
end
|
|
53
|
-
|
|
54
|
-
by_group = indexed_by("index_executions_on_group_hash_and_occurred_at")
|
|
55
|
-
.where(group_hash: groups.distinct.select(:group_hash), occurred_at: range, kind: kind)
|
|
56
|
-
fresh = where(kind: kind, occurred_at: [ from, to - FRESH_NAMES ].max..to).where("name LIKE ? ESCAPE '\\'", pattern)
|
|
57
|
-
ids = [ by_group, fresh ]
|
|
58
|
-
ids << indexed_by("idx_executions_with_preview").where(kind: kind, occurred_at: range)
|
|
59
|
-
.where.not(exception_preview: nil).where("exception_preview LIKE ? ESCAPE '\\'", pattern) if previews
|
|
60
|
-
from("#{quoted_table_name} NOT INDEXED").where(kind: kind, occurred_at: range)
|
|
61
|
-
.where(ids.map { |branch| "#{quoted_table_name}.id IN (#{branch.select(:id).to_sql})" }.join(" OR "))
|
|
62
|
-
end
|
|
63
|
-
|
|
64
|
-
# Executions of `kind` in `range` that are in one of `groups`, or recent
|
|
65
|
-
# enough (FRESH_NAMES) that their rollup may not exist yet. Each branch
|
|
66
|
-
# names its index and the outer lookup is by rowid, as in named_like:
|
|
67
|
-
# an OR over the two conditions made SQLite walk the whole window.
|
|
68
|
-
def self.in_groups_or_fresh(kind, groups, range)
|
|
69
|
-
by_group = indexed_by("index_executions_on_group_hash_and_occurred_at").where(group_hash: groups, occurred_at: range, kind: kind)
|
|
70
|
-
fresh = where(kind: kind, occurred_at: [ range.begin, range.end - FRESH_NAMES ].max..range.end)
|
|
71
|
-
return where(kind: kind, occurred_at: range).where(group_hash: groups).or(fresh) unless TelemetryRecord.sqlite?
|
|
72
|
-
|
|
73
|
-
from("#{quoted_table_name} NOT INDEXED").where(kind: kind, occurred_at: range)
|
|
74
|
-
.where([ by_group, fresh ].map { |branch| "#{quoted_table_name}.id IN (#{branch.select(:id).to_sql})" }.join(" OR "))
|
|
75
|
-
end
|
|
76
|
-
|
|
77
|
-
def self.indexed_by(index) = unscoped.from("#{quoted_table_name} INDEXED BY #{index}")
|
|
78
|
-
private_class_method :indexed_by
|
|
79
|
-
|
|
80
|
-
# Servers that reported an execution since `time`. Asked once per kind:
|
|
81
|
-
# there is no index led by occurred_at alone, so the obvious
|
|
82
|
-
# `where(occurred_at: time..).distinct.pluck(:server)` scans the whole
|
|
83
|
-
# table -- 15 to 26 seconds every five minutes on the platform's own
|
|
84
|
-
# 500k-row tenant, from SilentHostCheckJob -- while (kind, occurred_at)
|
|
85
|
-
# seeks straight to the window for each of the five kinds.
|
|
86
|
-
def self.servers_since(time)
|
|
87
|
-
KINDS.flat_map { |kind| where(kind: kind, occurred_at: time..).distinct.pluck(:server) }.uniq
|
|
88
|
-
end
|
|
89
|
-
|
|
90
|
-
# The schedule string each task key last ran with: the newest
|
|
91
|
-
# scheduled_task row per key carries it in detail. A task's runs share
|
|
92
|
-
# its group (the digest of its key), so that row is the last entry of
|
|
93
|
-
# its group_hash index -- one seek per key, a few dozen keys. A single
|
|
94
|
-
# MAX(id) GROUP BY task_key read every scheduled run ever kept instead:
|
|
95
|
-
# 10 s on the platform's own tenant.
|
|
96
|
-
def self.latest_schedules(keys)
|
|
97
|
-
keys.index_with do |key|
|
|
98
|
-
scheduled.where(group_hash: Record.group_hash(key), task_key: key).order(occurred_at: :desc)
|
|
99
|
-
.pick(Arel.sql("json_extract(detail, '$.schedule')"))
|
|
100
|
-
end.compact
|
|
101
|
-
end
|
|
102
|
-
|
|
103
|
-
CHILDREN = {
|
|
104
|
-
queries: Query, exceptions: Exception, cache_events: CacheEvent, mails: Mail,
|
|
105
|
-
broadcasts: Broadcast, outgoing_requests: OutgoingRequest, storage_ops: StorageOp,
|
|
106
|
-
view_renders: ViewRender, logs: Log, enqueued_jobs: EnqueuedJob, transactions: Transaction,
|
|
107
|
-
notifications: Notification, spans: Span, profiles: Profile, attachments: Attachment,
|
|
108
|
-
llm_calls: LlmCall
|
|
109
|
-
}.freeze
|
|
110
|
-
|
|
111
|
-
CHILDREN.each do |name, klass|
|
|
112
|
-
define_method(name) { klass.where(execution_id: execution_id) }
|
|
113
|
-
end
|
|
114
|
-
|
|
115
|
-
# All children merged and ordered for the waterfall timeline.
|
|
116
|
-
def timeline
|
|
117
|
-
CHILDREN.flat_map { |name, klass| klass.timeline_scope(execution_id).map { |r| r.timeline_entry(name, occurred_at) } }
|
|
118
|
-
.sort_by { |e| e[:offset] }
|
|
119
|
-
end
|
|
120
|
-
|
|
121
|
-
def failed?
|
|
122
|
-
(status && status >= 500) || outcome == "failed"
|
|
123
|
-
end
|
|
124
|
-
|
|
125
|
-
def duration_ms
|
|
126
|
-
duration / 1000.0
|
|
127
|
-
end
|
|
128
|
-
|
|
129
|
-
def queue_latency_ms
|
|
130
|
-
queue_latency && (queue_latency / 1000.0).round(1)
|
|
131
|
-
end
|
|
132
|
-
|
|
133
|
-
# The row every list surface shows for an execution: what the kind has
|
|
134
|
-
# in common, then what it adds.
|
|
135
|
-
def as_row
|
|
136
|
-
row = { execution_id: execution_id, kind: kind, name: name, status: status, outcome: outcome, duration: duration_ms.round(2),
|
|
137
|
-
occurred_at: occurred_at, deploy: deploy, server: server, user_ref: user_ref, tenant: app_tenant,
|
|
138
|
-
exception_preview: exception_preview }
|
|
139
|
-
case kind
|
|
140
|
-
when "request" then row.merge(method: self[:method], inertia_component: inertia_component, queries: counters["queries"])
|
|
141
|
-
when "job_attempt" then row.merge(queue: queue, attempt: attempt, job_id: job_id, queue_latency: queue_latency_ms)
|
|
142
|
-
when "scheduled_task" then row.merge(task_key: task_key)
|
|
143
|
-
else row
|
|
144
|
-
end
|
|
145
|
-
end
|
|
146
|
-
|
|
147
|
-
def children_count
|
|
148
|
-
counters.values.sum
|
|
149
|
-
end
|
|
150
|
-
end
|
|
151
|
-
end
|
|
152
|
-
end
|
|
@@ -1,32 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Railwatch
|
|
4
|
-
module Telemetry
|
|
5
|
-
# One immutable delivery waiting to be sent, or the record of one that no
|
|
6
|
-
# longer is. The body is the exact bytes that will go on the wire, kept
|
|
7
|
-
# until the delivery reaches a terminal state and then freed: a retry has
|
|
8
|
-
# to be the same delivery, and the receiver recognises it by those bytes.
|
|
9
|
-
class ExportDelivery < TelemetryRecord
|
|
10
|
-
DISPOSITIONS = %w[acked rejected expired discarded].freeze
|
|
11
|
-
|
|
12
|
-
belongs_to :export_destination
|
|
13
|
-
|
|
14
|
-
scope :live, -> { where.not(state: "done") }
|
|
15
|
-
scope :due, ->(now = Time.current) { live.where(state: "pending").where(next_attempt_at: ..now) }
|
|
16
|
-
scope :overdue, ->(now = Time.current) { live.where(expires_at: ...now) }
|
|
17
|
-
scope :oldest_first, -> { order(:id) }
|
|
18
|
-
|
|
19
|
-
def live? = state != "done"
|
|
20
|
-
|
|
21
|
-
def sending? = state == "sending"
|
|
22
|
-
|
|
23
|
-
# True when this claim is still the one allowed to finish the delivery.
|
|
24
|
-
# A sender that stalled past its lease may wake and complete its request
|
|
25
|
-
# anyway; the receiver's receipt makes that harmless, but it must not be
|
|
26
|
-
# able to overwrite what the new owner has since recorded.
|
|
27
|
-
def held_by?(token, generation)
|
|
28
|
-
sending? && claim_token == token && claim_generation == generation
|
|
29
|
-
end
|
|
30
|
-
end
|
|
31
|
-
end
|
|
32
|
-
end
|
|
@@ -1,66 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Railwatch
|
|
4
|
-
module Telemetry
|
|
5
|
-
# Where this database mirrors its telemetry, and everything durable about
|
|
6
|
-
# that relationship: who we are to the receiver, which token we were bound
|
|
7
|
-
# with, whether it is currently taking deliveries, and who holds the lease
|
|
8
|
-
# to send them.
|
|
9
|
-
class ExportDestination < TelemetryRecord
|
|
10
|
-
STATES = %w[ready deferred unauthorized inactive].freeze
|
|
11
|
-
COUNTERS = %w[acked rejected expired discarded shed].freeze
|
|
12
|
-
|
|
13
|
-
has_many :export_deliveries, dependent: :delete_all
|
|
14
|
-
|
|
15
|
-
validates :state, inclusion: { in: STATES }
|
|
16
|
-
|
|
17
|
-
def self.digest(value) = Digest::SHA256.hexdigest(value.to_s)
|
|
18
|
-
|
|
19
|
-
# The binding for this url and token. A different token for the same url
|
|
20
|
-
# is a different binding decision, not a silent rebind: queued bytes
|
|
21
|
-
# were admitted under the old credential and must not follow the new one
|
|
22
|
-
# to whatever tenant it belongs to.
|
|
23
|
-
def self.bind!(url:, token:, now: Time.current, retried: false)
|
|
24
|
-
row = find_or_initialize_by(url_sha256: digest(url))
|
|
25
|
-
row.url = url
|
|
26
|
-
row.producer_id ||= SecureRandom.uuid
|
|
27
|
-
fingerprint = digest(token)
|
|
28
|
-
if row.persisted? && row.credential_sha256 != fingerprint
|
|
29
|
-
row.update!(credential_sha256: fingerprint, state: "unauthorized", reason: "credential_changed",
|
|
30
|
-
retry_at: nil)
|
|
31
|
-
else
|
|
32
|
-
row.credential_sha256 = fingerprint
|
|
33
|
-
row.created_at ||= now
|
|
34
|
-
row.save!
|
|
35
|
-
end
|
|
36
|
-
row
|
|
37
|
-
rescue ActiveRecord::RecordNotUnique
|
|
38
|
-
# Two processes binding for the first time at once. One row wins; the
|
|
39
|
-
# loser wants that row, not an error.
|
|
40
|
-
raise if retried
|
|
41
|
-
|
|
42
|
-
bind!(url: url, token: token, now: now, retried: true)
|
|
43
|
-
end
|
|
44
|
-
|
|
45
|
-
# A pause the receiver asked for ends when it said it would. Only a
|
|
46
|
-
# credential problem needs a person: everything else is a delay, and a
|
|
47
|
-
# delay that never ends is an outage we caused ourselves.
|
|
48
|
-
# Needs a person before anything can move again: a credential that was
|
|
49
|
-
# changed, or a destination taken out of service. Queueing into one
|
|
50
|
-
# just fills it with work that rebind! will throw away.
|
|
51
|
-
def blocked? = %w[unauthorized inactive].include?(state)
|
|
52
|
-
|
|
53
|
-
def sendable?(now: Time.current)
|
|
54
|
-
return false unless %w[ready deferred].include?(state)
|
|
55
|
-
|
|
56
|
-
retry_at.nil? || retry_at <= now
|
|
57
|
-
end
|
|
58
|
-
|
|
59
|
-
def bump!(counter, by = 1)
|
|
60
|
-
return unless COUNTERS.include?(counter.to_s)
|
|
61
|
-
|
|
62
|
-
self.counters = counters.merge(counter.to_s => counters.fetch(counter.to_s, 0) + by)
|
|
63
|
-
end
|
|
64
|
-
end
|
|
65
|
-
end
|
|
66
|
-
end
|
|
@@ -1,72 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Railwatch
|
|
4
|
-
module Telemetry
|
|
5
|
-
# Periodic Puma / Solid Queue health sample from one process: thread pool
|
|
6
|
-
# utilisation, socket backlog, connection pool pressure, queue depth and
|
|
7
|
-
# the age of the oldest ready job.
|
|
8
|
-
class HealthSample < TelemetryRecord
|
|
9
|
-
# The gem reports every 15s, so a process that has not sampled in ten
|
|
10
|
-
# minutes is gone rather than idle.
|
|
11
|
-
LIVE_WINDOW = 10.minutes
|
|
12
|
-
BUCKET = 5.minutes
|
|
13
|
-
|
|
14
|
-
scope :recent, -> { order(sampled_at: :desc) }
|
|
15
|
-
scope :between, ->(from, to) { where(sampled_at: from..to) }
|
|
16
|
-
|
|
17
|
-
# The newest sample from each (server, pid) pair seen since `time`: one
|
|
18
|
-
# row per process still reporting.
|
|
19
|
-
def self.live(time = LIVE_WINDOW.ago)
|
|
20
|
-
where(id: where(sampled_at: time..).group(:server, :pid).select(Arel.sql("MAX(id)"))).order(:server, :pid)
|
|
21
|
-
end
|
|
22
|
-
|
|
23
|
-
# Health over the window, one point per BUCKET: thread utilisation is a
|
|
24
|
-
# percentage averaged across the processes that sampled into the bucket,
|
|
25
|
-
# backlog is summed across them, and the queue figures are the worst
|
|
26
|
-
# seen (any process's reading covers the whole queue).
|
|
27
|
-
def self.series(from, to, bucket: BUCKET)
|
|
28
|
-
slice = bucket_expression(bucket)
|
|
29
|
-
between(from, to).group(slice).order(slice)
|
|
30
|
-
.pluck(slice,
|
|
31
|
-
Arel.sql("AVG(CASE WHEN threads_max > 0 THEN threads_busy * 100.0 / threads_max END)"),
|
|
32
|
-
Arel.sql("SUM(backlog)"), Arel.sql("MAX(queue_depth)"), Arel.sql("MAX(queue_latency)"))
|
|
33
|
-
.map do |t, utilisation, backlog, depth, latency|
|
|
34
|
-
{ t: t, utilisation: utilisation.to_f.round(1), backlog: backlog.to_i,
|
|
35
|
-
queue_depth: depth.to_i, queue_latency: (latency.to_i / 1000.0).round(1) }
|
|
36
|
-
end
|
|
37
|
-
end
|
|
38
|
-
|
|
39
|
-
# Deepest depth reported for each queue across `samples`, most backed up
|
|
40
|
-
# first. Every worker reports the same shared queue, so the readings are
|
|
41
|
-
# merged with max, not summed.
|
|
42
|
-
def self.queue_depths(samples)
|
|
43
|
-
depths = samples.each_with_object({}) do |sample, out|
|
|
44
|
-
# A process without a job backend reports queues: nil, not {}.
|
|
45
|
-
(sample.detail["queues"] || {}).each { |queue, depth| out[queue] = [ out[queue].to_i, depth.to_i ].max }
|
|
46
|
-
end
|
|
47
|
-
depths.sort_by { |_queue, depth| -depth }.map { |queue, depth| { queue: queue, depth: depth } }
|
|
48
|
-
end
|
|
49
|
-
|
|
50
|
-
# The recurring task keys Solid Queue is running, from the newest live
|
|
51
|
-
# sample that carries a manifest (the gem sends one on every health
|
|
52
|
-
# sample). nil when no live process has reported one: an older gem, or
|
|
53
|
-
# nothing alive to ask.
|
|
54
|
-
def self.recurring_task_keys(since: LIVE_WINDOW.ago)
|
|
55
|
-
manifest = where(sampled_at: since..).where("json_extract(detail, '$.recurring_tasks') IS NOT NULL")
|
|
56
|
-
.recent.pick(Arel.sql("json_extract(detail, '$.recurring_tasks')"))
|
|
57
|
-
manifest && JSON.parse(manifest).keys
|
|
58
|
-
end
|
|
59
|
-
|
|
60
|
-
def self.bucket_expression(bucket)
|
|
61
|
-
seconds = bucket.to_i
|
|
62
|
-
Arel.sql("strftime('%Y-%m-%dT%H:%M:00Z', (strftime('%s', sampled_at) / #{seconds}) * #{seconds}, 'unixepoch')")
|
|
63
|
-
end
|
|
64
|
-
private_class_method :bucket_expression
|
|
65
|
-
|
|
66
|
-
def thread_utilisation
|
|
67
|
-
return nil if threads_max.to_i.zero?
|
|
68
|
-
threads_busy.to_f / threads_max
|
|
69
|
-
end
|
|
70
|
-
end
|
|
71
|
-
end
|
|
72
|
-
end
|
|
@@ -1,31 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Railwatch
|
|
4
|
-
module Telemetry
|
|
5
|
-
# One row per batch written into the telemetry database. In embedded mode
|
|
6
|
-
# it is also the delivery ledger: `batch_id` is the reporter's stable id
|
|
7
|
-
# for the batch (unique, so a replay is a no-op) and `followups` is the
|
|
8
|
-
# work the batch still owes outside its own transaction, nil once done.
|
|
9
|
-
class IngestBatch < TelemetryRecord
|
|
10
|
-
scope :recent, -> { order(received_at: :desc) }
|
|
11
|
-
scope :with_pending_followups, -> { where.not(followups: nil).order(:received_at) }
|
|
12
|
-
|
|
13
|
-
def self.committed(batch_id)
|
|
14
|
-
batch_id.present? ? find_by(batch_id: batch_id) : nil
|
|
15
|
-
end
|
|
16
|
-
|
|
17
|
-
# Runs the follow-ups a batch left behind and clears them. Safe to run
|
|
18
|
-
# more than once: the grouping commits a FollowupReceipt per (batch,
|
|
19
|
-
# group) in the railwatch database together with the occurrence count,
|
|
20
|
-
# so a second run finds the receipts and counts nothing. Clearing the
|
|
21
|
-
# outbox here is only what stops the maintenance drain from picking the
|
|
22
|
-
# row up again; it is not what makes the replay safe.
|
|
23
|
-
def drain_followups!(environment)
|
|
24
|
-
work = followups || {}
|
|
25
|
-
ids = Array(work["group_exception_ids"])
|
|
26
|
-
GroupExceptionsJob.new.perform(environment, ids, batch_id: batch_id) if ids.any?
|
|
27
|
-
update_columns(followups: nil)
|
|
28
|
-
end
|
|
29
|
-
end
|
|
30
|
-
end
|
|
31
|
-
end
|
|
@@ -1,37 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Railwatch
|
|
4
|
-
module Telemetry
|
|
5
|
-
class LlmCall < TelemetryRecord
|
|
6
|
-
include Child
|
|
7
|
-
|
|
8
|
-
# A tool invocation is a call in the same pipeline, so it shares this
|
|
9
|
-
# table; it just has a name where a model call has a model.
|
|
10
|
-
TOOL = "tool"
|
|
11
|
-
|
|
12
|
-
scope :models, -> { where.not(operation: TOOL) }
|
|
13
|
-
scope :tools, -> { where(operation: TOOL) }
|
|
14
|
-
|
|
15
|
-
NANOS_PER_DOLLAR = 1_000_000_000.0
|
|
16
|
-
|
|
17
|
-
# Dollars, or nil when the call is unpriced. Callers must render the
|
|
18
|
-
# difference: an unpriced call is not a free one.
|
|
19
|
-
def cost
|
|
20
|
-
cost_nanos && cost_nanos / NANOS_PER_DOLLAR
|
|
21
|
-
end
|
|
22
|
-
|
|
23
|
-
# What the app paid for, which is the count a bill is drawn from.
|
|
24
|
-
def total_tokens
|
|
25
|
-
[ input_tokens, output_tokens, cache_read_tokens, cache_write_tokens ].compact.sum
|
|
26
|
-
end
|
|
27
|
-
|
|
28
|
-
def timeline_label
|
|
29
|
-
tool? ? tool_name.to_s : "#{provider}/#{model}"
|
|
30
|
-
end
|
|
31
|
-
|
|
32
|
-
def tool?
|
|
33
|
-
operation == TOOL
|
|
34
|
-
end
|
|
35
|
-
end
|
|
36
|
-
end
|
|
37
|
-
end
|
|
@@ -1,91 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Railwatch
|
|
4
|
-
module Telemetry
|
|
5
|
-
class Log < TelemetryRecord
|
|
6
|
-
include Child
|
|
7
|
-
|
|
8
|
-
# Wraps each hit inside a snippet. One character for both ends so the
|
|
9
|
-
# frontend can split on it and mark the odd-numbered pieces; U+001F is
|
|
10
|
-
# not something a log line can realistically contain.
|
|
11
|
-
SNIPPET_MARK = "\u001F"
|
|
12
|
-
|
|
13
|
-
# "quoted phrase" | -excluded | bare, in the order they were typed.
|
|
14
|
-
TERM = /-?"[^"]*"|\S+/
|
|
15
|
-
|
|
16
|
-
scope :fts, ->(text) {
|
|
17
|
-
next none if text.to_s.strip.empty?
|
|
18
|
-
next where("message LIKE ?", "%#{sanitize_sql_like(text)}%") unless fts_available?
|
|
19
|
-
match = match_expression(text)
|
|
20
|
-
match ? where("logs.id IN (SELECT rowid FROM logs_fts WHERE logs_fts MATCH ?)", match) : none
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
class << self
|
|
24
|
-
# Free text -> an FTS5 MATCH expression. Quoted phrases stay whole,
|
|
25
|
-
# every other word becomes a prefix token so "widg" finds "widgets",
|
|
26
|
-
# and a leading "-" excludes -- but only when something positive is
|
|
27
|
-
# left to exclude from, since FTS5's NOT needs a left operand. Every
|
|
28
|
-
# term is emitted as a quoted string, which is what keeps FTS5 syntax
|
|
29
|
-
# (NEAR, *, :, parens) inert instead of raising a "syntax error".
|
|
30
|
-
# Returns nil when the text has nothing searchable in it.
|
|
31
|
-
def match_expression(text)
|
|
32
|
-
terms = text.to_s.scan(TERM).filter_map { |term| parse_term(term) }
|
|
33
|
-
positive = terms.reject(&:first).map(&:last)
|
|
34
|
-
negative = terms.select(&:first).map(&:last)
|
|
35
|
-
if positive.empty?
|
|
36
|
-
positive = negative
|
|
37
|
-
negative = []
|
|
38
|
-
end
|
|
39
|
-
return nil if positive.empty?
|
|
40
|
-
[ positive.join(" "), *negative.map { |term| "NOT #{term}" } ].join(" ")
|
|
41
|
-
end
|
|
42
|
-
|
|
43
|
-
# id => message with SNIPPET_MARK around each hit, for the rows the
|
|
44
|
-
# page is about to render. Empty unless we actually ran an FTS query.
|
|
45
|
-
def fts_snippets(ids, text)
|
|
46
|
-
return {} if ids.empty? || !fts_available?
|
|
47
|
-
match = match_expression(text)
|
|
48
|
-
return {} if match.nil?
|
|
49
|
-
sql = sanitize_sql_array([ "SELECT rowid, snippet(logs_fts, 0, ?, ?, ?, 24) FROM logs_fts WHERE logs_fts MATCH ? AND rowid IN (?)",
|
|
50
|
-
SNIPPET_MARK, SNIPPET_MARK, "…", match, ids ])
|
|
51
|
-
connection.select_rows(sql).to_h
|
|
52
|
-
end
|
|
53
|
-
|
|
54
|
-
# Postgres telemetry databases (SCOPE.md keeps them supported) have no
|
|
55
|
-
# logs_fts, and neither does a database created before the index was
|
|
56
|
-
# added. Every telemetry database is built from the same schema, so
|
|
57
|
-
# this is a process-wide fact rather than a per-environment one.
|
|
58
|
-
def fts_available?
|
|
59
|
-
return @fts_available if defined?(@fts_available)
|
|
60
|
-
@fts_available = connection.adapter_name.match?(/sqlite/i) &&
|
|
61
|
-
connection.select_value("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'logs_fts'").present?
|
|
62
|
-
end
|
|
63
|
-
|
|
64
|
-
private
|
|
65
|
-
|
|
66
|
-
# "term" -> [negated?, fts5 term], or nil if there is nothing left of
|
|
67
|
-
# it once the quotes and the minus sign come off.
|
|
68
|
-
def parse_term(term)
|
|
69
|
-
negated = term.start_with?("-") && term.length > 1
|
|
70
|
-
body = negated ? term[1..] : term
|
|
71
|
-
phrase = body.start_with?('"') && body.end_with?('"') && body.length > 1
|
|
72
|
-
body = body[1..-2].to_s if phrase
|
|
73
|
-
return nil unless body.match?(/[[:alnum:]]/)
|
|
74
|
-
[ negated, phrase ? quote(body) : "#{quote(body)}*" ]
|
|
75
|
-
end
|
|
76
|
-
|
|
77
|
-
def quote(term) = %("#{term.gsub('"', '""')}")
|
|
78
|
-
end
|
|
79
|
-
|
|
80
|
-
def as_row
|
|
81
|
-
{ id: id, level: level, message: message.first(2000), tags: tags, occurred_at: occurred_at, deploy: deploy,
|
|
82
|
-
execution_id: execution_id, execution_source: execution_source, execution_preview: execution_preview,
|
|
83
|
-
source: source, tenant: app_tenant, user_ref: user_ref, context: context }
|
|
84
|
-
end
|
|
85
|
-
|
|
86
|
-
def timeline_label
|
|
87
|
-
message.to_s.first(120)
|
|
88
|
-
end
|
|
89
|
-
end
|
|
90
|
-
end
|
|
91
|
-
end
|
|
@@ -1,92 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Railwatch
|
|
4
|
-
module Telemetry
|
|
5
|
-
class NPlusOne < TelemetryRecord
|
|
6
|
-
include Child
|
|
7
|
-
|
|
8
|
-
# A bound value in the SQL the gem stores. Railwatch::SqlNormalizer has
|
|
9
|
-
# already rewritten literals to "?" and one-element IN lists to "IN (?)"
|
|
10
|
-
# before an n+1 record is written; the bare-number branch only covers SQL
|
|
11
|
-
# that reached us unnormalized.
|
|
12
|
-
BIND = /(?:=\s*(?:\?|-?\d+)|IN\s*\(\s*(?:\?|-?\d+)\s*\))/i
|
|
13
|
-
|
|
14
|
-
# Matched against `shape`, which strips identifier quoting so one set of
|
|
15
|
-
# patterns covers SQLite/Postgres ("comments") and MySQL (`comments`).
|
|
16
|
-
# POLYMORPHIC has to be tried before CHILD_LOAD: an imageable_id filter
|
|
17
|
-
# matches both, and only the first one reads it as a polymorphic owner.
|
|
18
|
-
POLYMORPHIC = /\ASELECT\s+(?:\w+\.)?\*\s+FROM\s+(\w+)\s+WHERE\s+(?:\w+\.)?(\w+)_id\s*#{BIND}\s+AND\s+(?:\w+\.)?\2_type\s*#{BIND}/i
|
|
19
|
-
COUNT_LOAD = /\ASELECT\s+COUNT\(\*\)\s+(?:AS\s+\w+\s+)?FROM\s+(\w+)\s+WHERE\s+(?:\w+\.)?(\w+)_id\s*#{BIND}/i
|
|
20
|
-
EXISTS_LOAD = /\ASELECT\s+(?:1\s+AS\s+one|EXISTS\s*\(\s*SELECT\s+1)\b.*?\sFROM\s+(\w+)\s+WHERE\s+(?:\w+\.)?(\w+)_id\s*#{BIND}/i
|
|
21
|
-
CHILD_LOAD = /\ASELECT\s+(?:\w+\.)?\*\s+FROM\s+(\w+)\s+WHERE\s+(?:\w+\.)?(\w+)_id\s*#{BIND}/i
|
|
22
|
-
PARENT_LOAD = /\ASELECT\s+(?:\w+\.)?\*\s+FROM\s+(\w+)\s+WHERE\s+(?:\w+\.)?id\s*#{BIND}/i
|
|
23
|
-
|
|
24
|
-
def timeline_label
|
|
25
|
-
sql.to_s.first(120)
|
|
26
|
-
end
|
|
27
|
-
|
|
28
|
-
# A concrete fix for the repeated statement, read off its SQL shape:
|
|
29
|
-
# {kind:, parent:, association:, code:, explanation:}, or nil when the
|
|
30
|
-
# statement isn't a shape a preload or counter cache can fix.
|
|
31
|
-
def suggestion
|
|
32
|
-
s = shape
|
|
33
|
-
polymorphic_load(s) || count_load(s) || exists_load(s) || child_load(s) || parent_load(s)
|
|
34
|
-
end
|
|
35
|
-
|
|
36
|
-
private
|
|
37
|
-
|
|
38
|
-
def shape
|
|
39
|
-
sql.to_s.delete('"`').squeeze(" ").strip
|
|
40
|
-
end
|
|
41
|
-
|
|
42
|
-
def polymorphic_load(s)
|
|
43
|
-
m = POLYMORPHIC.match(s) or return nil
|
|
44
|
-
table, as = m[1], m[2]
|
|
45
|
-
{ kind: "polymorphic", parent: nil, association: table,
|
|
46
|
-
code: ".includes(:#{table})",
|
|
47
|
-
explanation: "Each owner loads its #{table} in a separate query. #{table} is polymorphic (belongs_to :#{as}), " \
|
|
48
|
-
"so add .includes(:#{table}) on the collection you iterate#{at_source}." }
|
|
49
|
-
end
|
|
50
|
-
|
|
51
|
-
def count_load(s)
|
|
52
|
-
m = COUNT_LOAD.match(s) or return nil
|
|
53
|
-
table, parent = m[1], m[2].classify
|
|
54
|
-
{ kind: "counter_cache", parent: parent, association: table,
|
|
55
|
-
code: "# app/models/#{m[1].singularize}.rb\nbelongs_to :#{m[2]}, counter_cache: true\n\n# then read\n#{m[2]}.#{table}_count",
|
|
56
|
-
explanation: "Each #{parent} counts its #{table} with its own COUNT query#{at_source}. " \
|
|
57
|
-
"A counter cache keeps the total on #{parent.tableize}.#{table}_count, so #{m[2]}.#{table}_count needs no query." }
|
|
58
|
-
end
|
|
59
|
-
|
|
60
|
-
def exists_load(s)
|
|
61
|
-
m = EXISTS_LOAD.match(s) or return nil
|
|
62
|
-
table, parent = m[1], m[2].classify
|
|
63
|
-
{ kind: "exists", parent: parent, association: table,
|
|
64
|
-
code: "#{parent}.includes(:#{table})\n\n# then\n#{m[2]}.#{table}.any?",
|
|
65
|
-
explanation: "Each #{parent} checks whether it has #{table} with its own query#{at_source}. " \
|
|
66
|
-
"Preload with #{parent}.includes(:#{table}) and call .any? on the loaded collection, or add a counter cache." }
|
|
67
|
-
end
|
|
68
|
-
|
|
69
|
-
def child_load(s)
|
|
70
|
-
m = CHILD_LOAD.match(s) or return nil
|
|
71
|
-
table, parent = m[1], m[2].classify
|
|
72
|
-
{ kind: "has_many", parent: parent, association: table,
|
|
73
|
-
code: "#{parent}.includes(:#{table})",
|
|
74
|
-
explanation: "Each #{parent} loads its #{table} in a separate query#{at_source}. " \
|
|
75
|
-
"Preload them with #{parent}.includes(:#{table}) where the #{parent.tableize} are loaded." }
|
|
76
|
-
end
|
|
77
|
-
|
|
78
|
-
def parent_load(s)
|
|
79
|
-
m = PARENT_LOAD.match(s) or return nil
|
|
80
|
-
association = m[1].singularize
|
|
81
|
-
{ kind: "belongs_to", parent: nil, association: association,
|
|
82
|
-
code: ".includes(:#{association})",
|
|
83
|
-
explanation: "Each row loads its #{association} one at a time. " \
|
|
84
|
-
"Add .includes(:#{association}) on the collection you iterate#{at_source}." }
|
|
85
|
-
end
|
|
86
|
-
|
|
87
|
-
def at_source
|
|
88
|
-
source.present? ? " at #{source}" : ""
|
|
89
|
-
end
|
|
90
|
-
end
|
|
91
|
-
end
|
|
92
|
-
end
|
|
@@ -1,50 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Railwatch
|
|
4
|
-
module Telemetry
|
|
5
|
-
# A monitored application's user, as seen in telemetry. Keyed by ref
|
|
6
|
-
# ("tenant:id" or "id") so the same person across tenants stays distinct.
|
|
7
|
-
class Person < TelemetryRecord
|
|
8
|
-
scope :recent, -> { order(last_seen_at: :desc) }
|
|
9
|
-
|
|
10
|
-
def self.touch_from_record(rec, at:)
|
|
11
|
-
touch_all([ [ rec, at ] ])
|
|
12
|
-
find_by(ref: rec["id"].to_s)
|
|
13
|
-
end
|
|
14
|
-
|
|
15
|
-
# One upsert per batch instead of a find-and-save per user record: the
|
|
16
|
-
# gem emits a user record once per user per hour per process, so a busy
|
|
17
|
-
# batch carries dozens. ON CONFLICT keeps first_seen_at and only
|
|
18
|
-
# overwrites name/email/tenant when the new value is present.
|
|
19
|
-
def self.touch_all(pairs)
|
|
20
|
-
rows = pairs.filter_map do |rec, at|
|
|
21
|
-
ref = rec["id"].to_s
|
|
22
|
-
next if ref.empty?
|
|
23
|
-
{ ref: ref, name: rec["name"].presence, email: rec["email"].presence, app_tenant: rec["tenant"].presence,
|
|
24
|
-
first_seen_at: at, last_seen_at: at, requests_count: 0, exceptions_count: 0 }
|
|
25
|
-
end
|
|
26
|
-
return 0 if rows.empty?
|
|
27
|
-
|
|
28
|
-
# Latest timestamp wins when the same ref repeats within a batch.
|
|
29
|
-
rows = rows.group_by { |r| r[:ref] }.map { |_ref, dup| dup.max_by { |r| r[:last_seen_at] } }
|
|
30
|
-
upsert_all(rows, unique_by: :ref, record_timestamps: false,
|
|
31
|
-
on_duplicate: Arel.sql(<<~SQL.squish))
|
|
32
|
-
name = COALESCE(excluded.name, people.name),
|
|
33
|
-
email = COALESCE(excluded.email, people.email),
|
|
34
|
-
app_tenant = COALESCE(excluded.app_tenant, people.app_tenant),
|
|
35
|
-
first_seen_at = COALESCE(people.first_seen_at, excluded.first_seen_at),
|
|
36
|
-
last_seen_at = MAX(people.last_seen_at, excluded.last_seen_at)
|
|
37
|
-
SQL
|
|
38
|
-
rows.size
|
|
39
|
-
end
|
|
40
|
-
|
|
41
|
-
def executions
|
|
42
|
-
Execution.where(user_ref: ref)
|
|
43
|
-
end
|
|
44
|
-
|
|
45
|
-
def display_name
|
|
46
|
-
name.presence || email.presence || ref
|
|
47
|
-
end
|
|
48
|
-
end
|
|
49
|
-
end
|
|
50
|
-
end
|