railwatch 0.9.4 → 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 +50 -0
- data/README.md +51 -60
- data/config/routes.rb +0 -59
- data/docs/ai-and-mcp.md +9 -8
- data/docs/configuration.md +35 -66
- 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/configuration.rb +21 -151
- data/lib/railwatch/engine.rb +9 -118
- data/lib/railwatch/patches.rb +0 -11
- data/lib/railwatch/reporter.rb +3 -19
- data/lib/railwatch/subscribers/process_info.rb +1 -2
- data/lib/railwatch/version.rb +1 -1
- data/lib/railwatch.rb +4 -62
- 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,251 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Railwatch
|
|
4
|
-
module Telemetry
|
|
5
|
-
# Pure per-environment rollup aggregation: time-bucketed series, grouped
|
|
6
|
-
# tables, and window-over-window summaries. Takes explicit from/to rather
|
|
7
|
-
# than reading a controller's window params, so it's the same math for the
|
|
8
|
-
# web dashboard (EnvironmentScoped) and the public API (Api::V1).
|
|
9
|
-
class Aggregations
|
|
10
|
-
GROUPED_SORT_FIELDS = %i[count p50 p95 p99 errors avg max].freeze
|
|
11
|
-
|
|
12
|
-
# Bucket widths a chart can be drawn at. Anything under an hour is read
|
|
13
|
-
# from the raw tables (rollups are hourly); an hour and up groups rollups.
|
|
14
|
-
STEPS = { "1m" => 1.minute, "5m" => 5.minutes, "15m" => 15.minutes, "1h" => 1.hour, "6h" => 6.hours, "1d" => 1.day }.freeze
|
|
15
|
-
# A step is offered for a window when it draws at least two points and at
|
|
16
|
-
# most this many: more bars than pixels is mush, and a sub-hour step over
|
|
17
|
-
# a long window is also a long raw scan.
|
|
18
|
-
MAX_POINTS = 400
|
|
19
|
-
|
|
20
|
-
# The bucket a window is drawn at unless the page asks for another one.
|
|
21
|
-
# Sub-hour steps scan raw rows, so they are the default only where that
|
|
22
|
-
# scan is a few thousand rows; a day and beyond stays on rollups.
|
|
23
|
-
def self.default_step(from, to)
|
|
24
|
-
span = to - from
|
|
25
|
-
if span <= 2.hours then "1m"
|
|
26
|
-
elsif span <= 12.hours then "5m"
|
|
27
|
-
elsif span <= 7.days then "1h"
|
|
28
|
-
else "6h"
|
|
29
|
-
end
|
|
30
|
-
end
|
|
31
|
-
|
|
32
|
-
# STEPS keys that make sense for the window, finest first.
|
|
33
|
-
def self.steps_for(from, to)
|
|
34
|
-
span = to - from
|
|
35
|
-
STEPS.select { |_key, step| (span / step).between?(2, MAX_POINTS) }.keys
|
|
36
|
-
end
|
|
37
|
-
|
|
38
|
-
# Time-bucketed series for charts, one point per `step` from `from` to `to`
|
|
39
|
-
# with empty buckets filled in: [{t, count, errors, client_errors, avg,
|
|
40
|
-
# p50, p95, p99}]. Durations in milliseconds; an empty bucket carries nil
|
|
41
|
-
# for them so a latency line breaks instead of dropping to zero.
|
|
42
|
-
def self.series(environment, record_type, from:, to:, group_hash: nil, step: nil, tenant: nil)
|
|
43
|
-
environment.with_telemetry { points(record_type, from: from, to: to, group_hash: group_hash, step: step, tenant: tenant) }
|
|
44
|
-
end
|
|
45
|
-
|
|
46
|
-
# `series` for a caller already inside environment.with_telemetry. A
|
|
47
|
-
# tenant narrows to one app_tenant, which rollups do not carry, so a
|
|
48
|
-
# tenant series reads raw rows at every step.
|
|
49
|
-
def self.points(record_type, from:, to:, group_hash: nil, step: nil, tenant: nil)
|
|
50
|
-
step = STEPS.fetch(step || default_step(from, to))
|
|
51
|
-
raw = step < 1.hour || tenant
|
|
52
|
-
points = raw ? raw_series(record_type, from, to, group_hash, step, tenant) : rollup_series(record_type, from, to, group_hash, step)
|
|
53
|
-
fill(points, from, to, step)
|
|
54
|
-
end
|
|
55
|
-
|
|
56
|
-
# Sums hourly rollups into `step`-wide buckets. Percentiles are the
|
|
57
|
-
# worst hour's, the same reading series always gave across groups.
|
|
58
|
-
def self.rollup_series(record_type, from, to, group_hash, step)
|
|
59
|
-
bucket = bucket_sql("bucket", step)
|
|
60
|
-
scope = Telemetry::Rollup.for_type(record_type).between(from, to)
|
|
61
|
-
scope = scope.where(group_hash: group_hash) if group_hash
|
|
62
|
-
scope.group(bucket).order(bucket)
|
|
63
|
-
.pluck(bucket, Arel.sql("SUM(count)"), Arel.sql("SUM(error_count)"), Arel.sql("SUM(client_error_count)"), Arel.sql("SUM(duration_sum)"), Arel.sql("MAX(p50)"), Arel.sql("MAX(p95)"), Arel.sql("MAX(p99)"))
|
|
64
|
-
.map { |b, c, e, ce, ds, p50, p95, p99| point(b, c, e, ce, ds, p50, p95, p99) }
|
|
65
|
-
end
|
|
66
|
-
|
|
67
|
-
# What a raw row of each record type is, and when it counts as an error
|
|
68
|
-
# (or a client error), mirroring RollupJob#error?. Both readings of the
|
|
69
|
-
# same rows must agree, which spec/models/telemetry/aggregations_spec.rb
|
|
70
|
-
# checks for every type.
|
|
71
|
-
RAW = {
|
|
72
|
-
"request" => [ -> { Telemetry::Execution.requests }, "status >= 500", "status BETWEEN 400 AND 499" ],
|
|
73
|
-
"job_attempt" => [ -> { Telemetry::Execution.jobs }, "outcome = 'failed'" ],
|
|
74
|
-
"scheduled_task" => [ -> { Telemetry::Execution.scheduled }, "outcome = 'failed'" ],
|
|
75
|
-
"command" => [ -> { Telemetry::Execution.commands }, "status != 0" ],
|
|
76
|
-
"channel_action" => [ -> { Telemetry::Execution.channels }, "outcome = 'failed'" ],
|
|
77
|
-
"query" => [ -> { Telemetry::Query.all } ],
|
|
78
|
-
"outgoing_request" => [ -> { Telemetry::OutgoingRequest.all }, "status_code >= 500 OR status_code IS NULL OR status_code = 0" ],
|
|
79
|
-
"cache_event" => [ -> { Telemetry::CacheEvent.all } ],
|
|
80
|
-
"mail" => [ -> { Telemetry::Mail.all }, "failed" ],
|
|
81
|
-
"visit" => [ -> { Telemetry::Visit.all }, "status = 'error'" ],
|
|
82
|
-
"span" => [ -> { Telemetry::Span.all }, "status = 'failed'" ],
|
|
83
|
-
"notification" => [ -> { Telemetry::Notification.all }, "failed" ],
|
|
84
|
-
"view_render" => [ -> { Telemetry::ViewRender.all } ],
|
|
85
|
-
"transaction" => [ -> { Telemetry::Transaction.all }, "outcome = 'rollback'" ],
|
|
86
|
-
"llm_call" => [ -> { Telemetry::LlmCall.models }, "status = 'failed'" ],
|
|
87
|
-
"llm_tool" => [ -> { Telemetry::LlmCall.tools }, "status = 'failed'" ]
|
|
88
|
-
}.freeze
|
|
89
|
-
|
|
90
|
-
# Sub-hour buckets straight from the raw rows, in one statement:
|
|
91
|
-
# counts and sums per bucket, and exact nearest-rank percentiles from a
|
|
92
|
-
# ROW_NUMBER over each bucket's durations. Rollups are hourly, so this
|
|
93
|
-
# is the only reading finer than an hour; it is also the freshest one,
|
|
94
|
-
# since the current hour's rollup is up to a minute behind.
|
|
95
|
-
def self.raw_series(record_type, from, to, group_hash, step, tenant = nil)
|
|
96
|
-
scope, error_sql, client_error_sql = RAW.fetch(record_type)
|
|
97
|
-
rows = scope.call.where(occurred_at: from..to)
|
|
98
|
-
rows = rows.where(group_hash: group_hash) if group_hash
|
|
99
|
-
rows = rows.where(app_tenant: tenant) if tenant
|
|
100
|
-
model = rows.model
|
|
101
|
-
inner = rows.select(bucket_sql("occurred_at", step).to_s + " AS b", "duration", flag_sql(error_sql) + " AS err", flag_sql(client_error_sql) + " AS cerr")
|
|
102
|
-
ranked = model.unscoped.from(inner, "r").select("b", "duration", "err", "cerr",
|
|
103
|
-
"ROW_NUMBER() OVER (PARTITION BY b ORDER BY duration) AS rn", "COUNT(*) OVER (PARTITION BY b) AS n")
|
|
104
|
-
model.unscoped.from(ranked, "w").group("b").order("b")
|
|
105
|
-
.pluck(Arel.sql("b"), Arel.sql("COUNT(*)"), Arel.sql("SUM(err)"), Arel.sql("SUM(cerr)"), Arel.sql("SUM(duration)"),
|
|
106
|
-
rank_sql(50), rank_sql(95), rank_sql(99))
|
|
107
|
-
.map { |b, c, e, ce, ds, p50, p95, p99| point(b, c, e, ce, ds, p50, p95, p99) }
|
|
108
|
-
end
|
|
109
|
-
|
|
110
|
-
# The timestamp columns a series buckets on: raw rows by occurred_at,
|
|
111
|
-
# rollups and release health by their hourly bucket.
|
|
112
|
-
BUCKET_COLUMNS = %w[occurred_at bucket].freeze
|
|
113
|
-
|
|
114
|
-
# Epoch seconds of the start of the `step`-wide bucket holding `column`.
|
|
115
|
-
# `column` must be one of BUCKET_COLUMNS and `step` is a Duration, so the
|
|
116
|
-
# fragment can only ever hold a listed column name and an integer literal.
|
|
117
|
-
def self.bucket_sql(column, step)
|
|
118
|
-
raise ArgumentError, "unknown bucket column #{column.inspect}" unless BUCKET_COLUMNS.include?(column)
|
|
119
|
-
seconds = Integer(step.to_i)
|
|
120
|
-
Arel.sql("(strftime('%s', #{column}) / #{seconds}) * #{seconds}")
|
|
121
|
-
end
|
|
122
|
-
|
|
123
|
-
# One point per bucket from `from` to `to`, keeping the computed ones and
|
|
124
|
-
# zero-filling the rest, so a quiet minute is a gap of the right width.
|
|
125
|
-
# A series with its own point shape passes a block building its empty one.
|
|
126
|
-
def self.fill(points, from, to, step)
|
|
127
|
-
by_bucket = points.index_by { |p| p[:t] }
|
|
128
|
-
first = Time.at((from.to_i / step.to_i) * step.to_i).utc
|
|
129
|
-
last = Time.at((to.to_i / step.to_i) * step.to_i).utc
|
|
130
|
-
(first.to_i..last.to_i).step(step.to_i).map do |t|
|
|
131
|
-
at = Time.at(t).utc
|
|
132
|
-
by_bucket[at] || (block_given? ? yield(at) : empty_point(at))
|
|
133
|
-
end
|
|
134
|
-
end
|
|
135
|
-
|
|
136
|
-
def self.point(bucket, count, errors, client_errors, duration_sum, p50, p95, p99)
|
|
137
|
-
{ t: Time.at(bucket).utc, count: count, errors: errors, client_errors: client_errors,
|
|
138
|
-
avg: count.zero? ? 0 : duration_sum / count / 1000.0, p50: p50 / 1000.0, p95: p95 / 1000.0, p99: p99 / 1000.0 }
|
|
139
|
-
end
|
|
140
|
-
|
|
141
|
-
def self.empty_point(t)
|
|
142
|
-
{ t: t, count: 0, errors: 0, client_errors: 0, avg: nil, p50: nil, p95: nil, p99: nil }
|
|
143
|
-
end
|
|
144
|
-
|
|
145
|
-
def self.flag_sql(predicate)
|
|
146
|
-
predicate ? "CASE WHEN #{predicate} THEN 1 ELSE 0 END" : "0"
|
|
147
|
-
end
|
|
148
|
-
|
|
149
|
-
# The nearest-rank percentile: the duration whose rank is ceil(n * p/100).
|
|
150
|
-
RANK_SQL = { 50 => Arel.sql("MAX(CASE WHEN rn = (n * 50 + 99) / 100 THEN duration END)"),
|
|
151
|
-
95 => Arel.sql("MAX(CASE WHEN rn = (n * 95 + 99) / 100 THEN duration END)"),
|
|
152
|
-
99 => Arel.sql("MAX(CASE WHEN rn = (n * 99 + 99) / 100 THEN duration END)") }.freeze
|
|
153
|
-
|
|
154
|
-
def self.rank_sql(percent)
|
|
155
|
-
RANK_SQL.fetch(percent)
|
|
156
|
-
end
|
|
157
|
-
|
|
158
|
-
private_class_method :rollup_series, :raw_series, :point, :empty_point, :flag_sql, :rank_sql
|
|
159
|
-
|
|
160
|
-
# Per-group table rows for a record type in the window: count/error totals,
|
|
161
|
-
# merged percentiles (via Telemetry::Rollup.summarize), and a sparkline.
|
|
162
|
-
def self.grouped(environment, record_type, from:, to:, limit: 100, order: nil, dir: nil)
|
|
163
|
-
order = GROUPED_SORT_FIELDS.include?(order.to_s.to_sym) ? order.to_s.to_sym : :count
|
|
164
|
-
sign = dir.to_s == "asc" ? 1 : -1
|
|
165
|
-
# Same minute cache as summary_with_delta, for the same reason: the
|
|
166
|
-
# 200 busiest query groups on the rebulk environment span 1,500 rollup
|
|
167
|
-
# rows and 55,000 centroids, and merging those digests is 480ms that
|
|
168
|
-
# only changes when RollupJob writes.
|
|
169
|
-
key = [ "aggregations", "grouped", environment.id, record_type, limit, order, sign, from.to_i / 60, to.to_i / 60 ]
|
|
170
|
-
cached(key) { grouped_uncached(environment, record_type, from: from, to: to, limit: limit, order: order, sign: sign) }
|
|
171
|
-
end
|
|
172
|
-
|
|
173
|
-
# The minute cache exists because the platform's rollups only change
|
|
174
|
-
# when RollupJob writes. Embedded, every batch updates them and one
|
|
175
|
-
# person is looking, so the cache would only make the page a minute
|
|
176
|
-
# stale for nothing.
|
|
177
|
-
def self.cached(key, &block)
|
|
178
|
-
return yield if Railwatch.config.local?
|
|
179
|
-
Rails.cache.fetch(key, expires_in: 1.minute, &block)
|
|
180
|
-
end
|
|
181
|
-
|
|
182
|
-
def self.grouped_uncached(environment, record_type, from:, to:, limit:, order:, sign:)
|
|
183
|
-
environment.with_telemetry do
|
|
184
|
-
rows = Telemetry::Rollup.for_type(record_type).between(from, to).to_a
|
|
185
|
-
groups = rows.group_by(&:group_hash)
|
|
186
|
-
# Merging t-digests is the expensive part (the queries page has 2,000
|
|
187
|
-
# groups over 8,500 rows: 500ms of digest merges for a table that
|
|
188
|
-
# shows 200). Sort on what the columns already hold -- counts, sums
|
|
189
|
-
# and maxima need no digest -- and only merge percentiles for the
|
|
190
|
-
# groups that make the cut. A percentile sort falls back to the full
|
|
191
|
-
# merge, since the ranking itself needs the digest.
|
|
192
|
-
cheap = %i[count errors avg max].include?(order)
|
|
193
|
-
ranked = groups.map { |group_hash, group_rows| [ group_hash, group_rows, cheap ? cheap_summary(group_rows) : Telemetry::Rollup.summarize(group_rows) ] }
|
|
194
|
-
ranked.sort_by! { |_, _, summary| summary[order] * sign }
|
|
195
|
-
ranked.first(limit).map do |group_hash, group_rows, summary|
|
|
196
|
-
summary = Telemetry::Rollup.summarize(group_rows) if cheap
|
|
197
|
-
{
|
|
198
|
-
group_hash: group_hash, name: group_rows.max_by(&:bucket).name,
|
|
199
|
-
count: summary[:count], errors: summary[:errors], client_errors: summary[:client_errors],
|
|
200
|
-
avg: (summary[:avg] / 1000.0).round(2), p50: (summary[:p50] / 1000.0).round(2),
|
|
201
|
-
p95: (summary[:p95] / 1000.0).round(2), p99: (summary[:p99] / 1000.0).round(2),
|
|
202
|
-
max: (summary[:max] / 1000.0).round(2), sparkline: sparkline(group_rows, from, to)
|
|
203
|
-
}
|
|
204
|
-
end
|
|
205
|
-
end
|
|
206
|
-
end
|
|
207
|
-
|
|
208
|
-
# The digest-free half of Rollup.summarize: everything the sort keys
|
|
209
|
-
# that are not percentiles need.
|
|
210
|
-
def self.cheap_summary(rows)
|
|
211
|
-
count = rows.sum(&:count)
|
|
212
|
-
{ count: count, errors: rows.sum(&:error_count),
|
|
213
|
-
avg: count.zero? ? 0 : (rows.sum(&:duration_sum) / count), max: rows.map(&:duration_max).max }
|
|
214
|
-
end
|
|
215
|
-
|
|
216
|
-
# { current: Summary, previous: Summary } so pages/endpoints can show deltas.
|
|
217
|
-
# Cached for a minute per window. The two totals merge every rollup
|
|
218
|
-
# digest in both windows (18,000 rows, 355ms on the rebulk environment's
|
|
219
|
-
# queries page) to produce four numbers that only move when RollupJob
|
|
220
|
-
# writes, which is at most once a minute per bucket; without this the
|
|
221
|
-
# merge ran again on every page load.
|
|
222
|
-
def self.summary_with_delta(environment, record_type, from:, to:, previous_from:, previous_to:, group_hash: nil)
|
|
223
|
-
key = [ "aggregations", "summary_with_delta", environment.id, record_type, group_hash,
|
|
224
|
-
from.to_i / 60, to.to_i / 60, previous_from.to_i / 60, previous_to.to_i / 60 ]
|
|
225
|
-
cached(key) do
|
|
226
|
-
environment.with_telemetry do
|
|
227
|
-
current = Telemetry::Rollup.for_type(record_type).between(from, to)
|
|
228
|
-
previous = Telemetry::Rollup.for_type(record_type).between(previous_from, previous_to)
|
|
229
|
-
if group_hash
|
|
230
|
-
current = current.where(group_hash: group_hash)
|
|
231
|
-
previous = previous.where(group_hash: group_hash)
|
|
232
|
-
end
|
|
233
|
-
{ current: Telemetry::Rollup.summarize(current), previous: Telemetry::Rollup.summarize(previous) }
|
|
234
|
-
end
|
|
235
|
-
end
|
|
236
|
-
end
|
|
237
|
-
|
|
238
|
-
# Hourly counts for a set of same-window rollup rows, zero-filled, coarsened
|
|
239
|
-
# to at most 30 points for wide windows (7d/30d).
|
|
240
|
-
def self.sparkline(rows, from, to)
|
|
241
|
-
hours = [ ((to - from) / 1.hour).ceil, 1 ].max
|
|
242
|
-
coarsen = (hours / 30.0).ceil
|
|
243
|
-
base = from.beginning_of_hour
|
|
244
|
-
buckets = Hash.new(0)
|
|
245
|
-
rows.each { |r| buckets[((r.bucket - base) / 1.hour).to_i / coarsen] += r.count }
|
|
246
|
-
bucket_count = (hours.to_f / coarsen).ceil
|
|
247
|
-
(0...bucket_count).map { |i| buckets[i] }
|
|
248
|
-
end
|
|
249
|
-
end
|
|
250
|
-
end
|
|
251
|
-
end
|
|
@@ -1,31 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Railwatch
|
|
4
|
-
module Telemetry
|
|
5
|
-
# A file an app attached to an execution or exception with Railwatch.attach.
|
|
6
|
-
# `data` is gzip of the original bytes.
|
|
7
|
-
class Attachment < TelemetryRecord
|
|
8
|
-
include Child
|
|
9
|
-
|
|
10
|
-
# A record has to fit inside the API's decoded request ceiling. Keeping
|
|
11
|
-
# the per-attachment ceiling equal to it preserves every valid existing
|
|
12
|
-
# payload while bounding reads of legacy rows.
|
|
13
|
-
MAX_BODY_BYTES = IngestRequestBodyLimit::MAX_BYTES
|
|
14
|
-
|
|
15
|
-
def body
|
|
16
|
-
BoundedGzip.decompress(data, max_bytes: MAX_BODY_BYTES)
|
|
17
|
-
end
|
|
18
|
-
|
|
19
|
-
# Only text-ish payloads are safe to render in the browser; everything
|
|
20
|
-
# else downloads. Rendered as text/plain either way, so a stored
|
|
21
|
-
# text/html attachment can never execute against our origin.
|
|
22
|
-
def viewable?
|
|
23
|
-
content_type.to_s.start_with?("text/") || content_type.to_s.split(";").first == "application/json"
|
|
24
|
-
end
|
|
25
|
-
|
|
26
|
-
def timeline_label
|
|
27
|
-
name.to_s.first(120)
|
|
28
|
-
end
|
|
29
|
-
end
|
|
30
|
-
end
|
|
31
|
-
end
|
|
@@ -1,101 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Railwatch
|
|
4
|
-
require "stringio"
|
|
5
|
-
require "zlib"
|
|
6
|
-
|
|
7
|
-
module Telemetry
|
|
8
|
-
# Reads untrusted gzip streams without ever asking zlib for more than the
|
|
9
|
-
# configured output ceiling. Ingest uses verify! without retaining the
|
|
10
|
-
# expanded bytes; read paths either return a bounded body or a bounded
|
|
11
|
-
# prefix for displays that can be truncated.
|
|
12
|
-
module BoundedGzip
|
|
13
|
-
READ_SIZE = 64.kilobytes
|
|
14
|
-
|
|
15
|
-
class Error < ArgumentError; end
|
|
16
|
-
class Malformed < Error; end
|
|
17
|
-
class TooLarge < Error; end
|
|
18
|
-
class SizeMismatch < Error; end
|
|
19
|
-
|
|
20
|
-
module_function
|
|
21
|
-
|
|
22
|
-
def decompress(compressed, max_bytes:)
|
|
23
|
-
output = String.new(encoding: Encoding::BINARY)
|
|
24
|
-
each_chunk(compressed, max_bytes: max_bytes) { |chunk| output << chunk }
|
|
25
|
-
output
|
|
26
|
-
end
|
|
27
|
-
|
|
28
|
-
# Returns [prefix, truncated?]. Once one byte beyond the display limit is
|
|
29
|
-
# observed, decompression stops instead of expanding the rest of a legacy
|
|
30
|
-
# gzip bomb merely to prove that it was truncated.
|
|
31
|
-
def decompress_capped(compressed, limit:)
|
|
32
|
-
output = String.new(encoding: Encoding::BINARY)
|
|
33
|
-
each_chunk(compressed, max_bytes: limit) { |chunk| output << chunk }
|
|
34
|
-
[ output, false ]
|
|
35
|
-
rescue TooLarge
|
|
36
|
-
[ output, true ]
|
|
37
|
-
end
|
|
38
|
-
|
|
39
|
-
def verify!(compressed, max_bytes:, expected_bytes:, utf8: false)
|
|
40
|
-
unless expected_bytes.is_a?(Integer) && expected_bytes.between?(0, max_bytes)
|
|
41
|
-
raise SizeMismatch, "declared size #{expected_bytes} exceeds the #{max_bytes} byte ceiling"
|
|
42
|
-
end
|
|
43
|
-
|
|
44
|
-
validator = Encoding::Converter.new(Encoding::UTF_8, Encoding::UTF_16LE) if utf8
|
|
45
|
-
actual_bytes = each_chunk(compressed, max_bytes: max_bytes) do |chunk|
|
|
46
|
-
validator&.convert(chunk)
|
|
47
|
-
end
|
|
48
|
-
validator&.finish
|
|
49
|
-
return actual_bytes if actual_bytes == expected_bytes
|
|
50
|
-
|
|
51
|
-
raise SizeMismatch, "declared size #{expected_bytes} does not match decompressed size #{actual_bytes}"
|
|
52
|
-
rescue Encoding::InvalidByteSequenceError, Encoding::UndefinedConversionError
|
|
53
|
-
raise Malformed, "decompressed content is not valid UTF-8"
|
|
54
|
-
end
|
|
55
|
-
|
|
56
|
-
def utf8!(text)
|
|
57
|
-
text.force_encoding(Encoding::UTF_8)
|
|
58
|
-
return text if text.valid_encoding?
|
|
59
|
-
|
|
60
|
-
raise Malformed, "decompressed content is not valid UTF-8"
|
|
61
|
-
end
|
|
62
|
-
|
|
63
|
-
def each_chunk(compressed, max_bytes:)
|
|
64
|
-
raise ArgumentError, "max_bytes must be non-negative" unless max_bytes.is_a?(Integer) && max_bytes >= 0
|
|
65
|
-
|
|
66
|
-
# StringIO and zlib operate on bytes regardless of the String's
|
|
67
|
-
# encoding. Avoid String#b here: it duplicates even an already-binary
|
|
68
|
-
# database BLOB, adding another full compressed-body allocation.
|
|
69
|
-
source = StringIO.new(compressed.to_s)
|
|
70
|
-
reader = Zlib::GzipReader.new(source)
|
|
71
|
-
total = 0
|
|
72
|
-
loop do
|
|
73
|
-
# The extra byte proves that a stream at the boundary has ended. Only
|
|
74
|
-
# the portion up to the ceiling is ever yielded or retained.
|
|
75
|
-
chunk = reader.readpartial([ READ_SIZE, max_bytes - total + 1 ].min)
|
|
76
|
-
if total + chunk.bytesize > max_bytes
|
|
77
|
-
remaining = max_bytes - total
|
|
78
|
-
yield chunk.byteslice(0, remaining) if remaining.positive?
|
|
79
|
-
raise TooLarge, "decompressed content exceeds #{max_bytes} bytes"
|
|
80
|
-
end
|
|
81
|
-
|
|
82
|
-
total += chunk.bytesize
|
|
83
|
-
yield chunk
|
|
84
|
-
end
|
|
85
|
-
rescue EOFError
|
|
86
|
-
# GzipReader reads ahead in fixed-size blocks. `unused` catches bytes
|
|
87
|
-
# already buffered past the gzip member; checking the underlying IO is
|
|
88
|
-
# also required when the member ends exactly on a read boundary and
|
|
89
|
-
# the trailing bytes have not been pulled into zlib's buffer yet.
|
|
90
|
-
raise Malformed, "gzip stream has trailing data" if reader.unused.present? || !source.eof?
|
|
91
|
-
|
|
92
|
-
total
|
|
93
|
-
rescue Zlib::Error => e
|
|
94
|
-
raise Malformed, "invalid gzip stream: #{e.message}"
|
|
95
|
-
ensure
|
|
96
|
-
reader&.close
|
|
97
|
-
end
|
|
98
|
-
private_class_method :each_chunk
|
|
99
|
-
end
|
|
100
|
-
end
|
|
101
|
-
end
|
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Railwatch
|
|
4
|
-
module Telemetry
|
|
5
|
-
class CacheEvent < TelemetryRecord
|
|
6
|
-
include Child
|
|
7
|
-
|
|
8
|
-
# `type` is the cache operation (hit, miss, write...), not STI.
|
|
9
|
-
self.inheritance_column = nil
|
|
10
|
-
|
|
11
|
-
def timeline_label
|
|
12
|
-
"#{type} #{key}".first(120)
|
|
13
|
-
end
|
|
14
|
-
end
|
|
15
|
-
end
|
|
16
|
-
end
|
|
@@ -1,53 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Railwatch
|
|
4
|
-
module Telemetry
|
|
5
|
-
# Shared behaviour for every record that belongs to an execution.
|
|
6
|
-
module Child
|
|
7
|
-
extend ActiveSupport::Concern
|
|
8
|
-
|
|
9
|
-
included do
|
|
10
|
-
scope :recent, -> { order(occurred_at: :desc) }
|
|
11
|
-
scope :between, ->(from, to) { where(occurred_at: from..to) }
|
|
12
|
-
scope :in_execution, ->(id) { where(execution_id: id) }
|
|
13
|
-
end
|
|
14
|
-
|
|
15
|
-
class_methods do
|
|
16
|
-
# One execution's rows for the waterfall; a child that resolves a
|
|
17
|
-
# column from elsewhere (Query) overrides this.
|
|
18
|
-
def timeline_scope(execution_id)
|
|
19
|
-
in_execution(execution_id)
|
|
20
|
-
end
|
|
21
|
-
end
|
|
22
|
-
|
|
23
|
-
def execution
|
|
24
|
-
Execution.find_by(execution_id: execution_id)
|
|
25
|
-
end
|
|
26
|
-
|
|
27
|
-
def duration_ms
|
|
28
|
-
respond_to?(:duration) && duration ? duration / 1000.0 : nil
|
|
29
|
-
end
|
|
30
|
-
|
|
31
|
-
# parent_start is passed in by Execution#timeline, which already holds
|
|
32
|
-
# the parent. Resolving it here instead cost one query per child row,
|
|
33
|
-
# so a waterfall of a few hundred logs and queries was a few hundred
|
|
34
|
-
# lookups of the same execution.
|
|
35
|
-
def timeline_entry(type, parent_start = nil)
|
|
36
|
-
parent_start ||= execution&.occurred_at
|
|
37
|
-
{
|
|
38
|
-
type: type.to_s.singularize,
|
|
39
|
-
id: id,
|
|
40
|
-
offset: parent_start ? ((occurred_at - parent_start) * 1000.0).round(3) : 0,
|
|
41
|
-
duration: duration_ms,
|
|
42
|
-
label: timeline_label,
|
|
43
|
-
stage: execution_stage,
|
|
44
|
-
source: (self[:source] if has_attribute?(:source))
|
|
45
|
-
}
|
|
46
|
-
end
|
|
47
|
-
|
|
48
|
-
def timeline_label
|
|
49
|
-
try(:name) || try(:key) || try(:message)&.first(120) || self.class.name.demodulize
|
|
50
|
-
end
|
|
51
|
-
end
|
|
52
|
-
end
|
|
53
|
-
end
|
|
@@ -1,99 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Railwatch
|
|
4
|
-
module Telemetry
|
|
5
|
-
# Stable keyset pagination for high-volume telemetry tables. Cursors are
|
|
6
|
-
# signed and bind their sort shape, so clients cannot inject columns or use
|
|
7
|
-
# a cursor from one resource/order against another.
|
|
8
|
-
class CursorPage
|
|
9
|
-
class InvalidCursor < ArgumentError; end
|
|
10
|
-
|
|
11
|
-
DEFAULT_LIMIT = 50
|
|
12
|
-
MAX_LIMIT = 200
|
|
13
|
-
ORDERS = {
|
|
14
|
-
recent: { column: :occurred_at, direction: :desc, type: :time },
|
|
15
|
-
slowest: { column: :duration, direction: :desc, type: :integer }
|
|
16
|
-
}.freeze
|
|
17
|
-
|
|
18
|
-
def self.call(scope, cursor: nil, limit: nil, order: :recent, context: nil)
|
|
19
|
-
new(scope, cursor: cursor, limit: limit, order: order, context: context).call
|
|
20
|
-
end
|
|
21
|
-
|
|
22
|
-
def initialize(scope, cursor:, limit:, order:, context:)
|
|
23
|
-
@scope = scope
|
|
24
|
-
@cursor = cursor
|
|
25
|
-
parsed_limit = limit.to_i
|
|
26
|
-
parsed_limit = DEFAULT_LIMIT unless parsed_limit.positive?
|
|
27
|
-
@limit = [ parsed_limit, MAX_LIMIT ].min
|
|
28
|
-
@order_name = order.to_sym
|
|
29
|
-
@order = ORDERS.fetch(@order_name)
|
|
30
|
-
@context = context.to_s
|
|
31
|
-
end
|
|
32
|
-
|
|
33
|
-
def call
|
|
34
|
-
relation = apply_cursor(scope)
|
|
35
|
-
relation = relation.from("#{relation.klass.quoted_table_name} INDEXED BY idx_queries_slowest") if force_slowest_index?
|
|
36
|
-
rows = relation.reorder(order[:column] => order[:direction], id: order[:direction]).limit(limit + 1).to_a
|
|
37
|
-
has_more = rows.length > limit
|
|
38
|
-
rows = rows.first(limit)
|
|
39
|
-
next_cursor = encode(rows.last) if has_more
|
|
40
|
-
[ rows, { limit: limit, next_cursor: next_cursor, has_more: has_more } ]
|
|
41
|
-
end
|
|
42
|
-
|
|
43
|
-
private
|
|
44
|
-
|
|
45
|
-
attr_reader :scope, :cursor, :limit, :order, :order_name, :context
|
|
46
|
-
|
|
47
|
-
# SQLite's planner costs the duration-led covering index higher than
|
|
48
|
-
# scanning the whole occurred_at window and sorting it, even with fresh
|
|
49
|
-
# statistics, so it never picks it on its own. Forced only for the one
|
|
50
|
-
# (model, order) pair the index exists for.
|
|
51
|
-
# INDEXED BY is SQLite's own syntax, and telemetry is a SQLite database
|
|
52
|
-
# in both the platform and an embedded install. Ask anyway, so pointing
|
|
53
|
-
# this at anything else degrades to the planner's own choice rather
|
|
54
|
-
# than a syntax error on every page load.
|
|
55
|
-
def force_slowest_index?
|
|
56
|
-
order_name == :slowest && scope.klass == Telemetry::Query &&
|
|
57
|
-
scope.klass.connection.adapter_name.match?(/sqlite/i)
|
|
58
|
-
end
|
|
59
|
-
|
|
60
|
-
def apply_cursor(relation)
|
|
61
|
-
values = decode
|
|
62
|
-
return relation unless values
|
|
63
|
-
|
|
64
|
-
column = relation.klass.arel_table[order[:column]]
|
|
65
|
-
id = relation.klass.arel_table[:id]
|
|
66
|
-
comparator = order[:direction] == :desc ? :lt : :gt
|
|
67
|
-
relation.where(column.public_send(comparator, values[:value])
|
|
68
|
-
.or(column.eq(values[:value]).and(id.public_send(comparator, values[:id]))))
|
|
69
|
-
end
|
|
70
|
-
|
|
71
|
-
def encode(row)
|
|
72
|
-
return unless row
|
|
73
|
-
value = row.public_send(order[:column])
|
|
74
|
-
value = value.iso8601(6) if order[:type] == :time
|
|
75
|
-
# The page size is deliberately not bound: changing it mid-scroll must
|
|
76
|
-
# not hard-fail. The cursor is a stable position, not a security
|
|
77
|
-
# boundary -- the scope it is applied to is already tenant-scoped here.
|
|
78
|
-
verifier.generate({ v: 1, model: scope.klass.name, order: order_name,
|
|
79
|
-
context: context, value: value, id: row.id })
|
|
80
|
-
end
|
|
81
|
-
|
|
82
|
-
def decode
|
|
83
|
-
return if cursor.blank?
|
|
84
|
-
payload = verifier.verify(cursor.to_s).symbolize_keys
|
|
85
|
-
valid_shape = payload[:v] == 1 && payload[:model] == scope.klass.name &&
|
|
86
|
-
payload[:order].to_s == order_name.to_s && payload[:context] == context
|
|
87
|
-
raise InvalidCursor, "invalid telemetry cursor" unless valid_shape
|
|
88
|
-
value = order[:type] == :time ? Time.iso8601(payload[:value].to_s) : Integer(payload[:value].to_s, 10)
|
|
89
|
-
{ value: value, id: Integer(payload[:id].to_s, 10) }
|
|
90
|
-
rescue ActiveSupport::MessageVerifier::InvalidSignature, ArgumentError, TypeError
|
|
91
|
-
raise InvalidCursor, "invalid telemetry cursor"
|
|
92
|
-
end
|
|
93
|
-
|
|
94
|
-
def verifier
|
|
95
|
-
Rails.application.message_verifier("telemetry_cursor")
|
|
96
|
-
end
|
|
97
|
-
end
|
|
98
|
-
end
|
|
99
|
-
end
|
|
@@ -1,28 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Railwatch
|
|
4
|
-
module Telemetry
|
|
5
|
-
class Exception < TelemetryRecord
|
|
6
|
-
include Child
|
|
7
|
-
|
|
8
|
-
self.table_name = "exceptions"
|
|
9
|
-
|
|
10
|
-
scope :unhandled, -> { where(handled: false) }
|
|
11
|
-
|
|
12
|
-
def as_row
|
|
13
|
-
{ id: id, class_name: class_name, message: message.first(500), handled: handled, severity: severity, source: source,
|
|
14
|
-
file: file, line: line, occurred_at: occurred_at, deploy: deploy, execution_id: execution_id,
|
|
15
|
-
execution_source: execution_source, execution_preview: execution_preview, user_ref: user_ref, tenant: app_tenant,
|
|
16
|
-
group_hash: group_hash }
|
|
17
|
-
end
|
|
18
|
-
|
|
19
|
-
def timeline_label
|
|
20
|
-
"#{class_name}: #{message.to_s.first(100)}"
|
|
21
|
-
end
|
|
22
|
-
|
|
23
|
-
def app_frames
|
|
24
|
-
frames.select { |f| f["in_app"] }
|
|
25
|
-
end
|
|
26
|
-
end
|
|
27
|
-
end
|
|
28
|
-
end
|