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,37 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Railwatch
|
|
4
|
-
module Telemetry
|
|
5
|
-
# A sampled stack profile of one execution. `stacks` is gzip of collapsed
|
|
6
|
-
# stacks ("outer;inner;leaf count\n"), the format flamegraph tools read.
|
|
7
|
-
class Profile < TelemetryRecord
|
|
8
|
-
include Child
|
|
9
|
-
|
|
10
|
-
# Railwatch's profiler caps collapsed stacks at 4 MiB before compression.
|
|
11
|
-
# Enforce the same contract here even when a client lies about its size.
|
|
12
|
-
MAX_INGEST_BYTES = 4.megabytes
|
|
13
|
-
|
|
14
|
-
# A minute of wall profiling can decompress to tens of megabytes; the
|
|
15
|
-
# page only ever renders a flamegraph, so the payload is cut here rather
|
|
16
|
-
# than shipped whole to the browser.
|
|
17
|
-
MAX_COLLAPSED_BYTES = 2.megabytes
|
|
18
|
-
|
|
19
|
-
def collapsed
|
|
20
|
-
BoundedGzip.utf8!(BoundedGzip.decompress(stacks, max_bytes: MAX_INGEST_BYTES))
|
|
21
|
-
end
|
|
22
|
-
|
|
23
|
-
# [text, truncated?] - cut on a line boundary so the parser never sees
|
|
24
|
-
# half a stack line.
|
|
25
|
-
def collapsed_capped(limit = MAX_COLLAPSED_BYTES)
|
|
26
|
-
text, truncated = BoundedGzip.decompress_capped(stacks, limit: [ limit, MAX_INGEST_BYTES ].min)
|
|
27
|
-
text = text.sub(/[^\n]*\z/, "") if truncated
|
|
28
|
-
|
|
29
|
-
[ BoundedGzip.utf8!(text), truncated ]
|
|
30
|
-
end
|
|
31
|
-
|
|
32
|
-
def timeline_label
|
|
33
|
-
"#{profiler} profile (#{samples} samples)"
|
|
34
|
-
end
|
|
35
|
-
end
|
|
36
|
-
end
|
|
37
|
-
end
|
|
@@ -1,57 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Railwatch
|
|
4
|
-
module Telemetry
|
|
5
|
-
class Query < TelemetryRecord
|
|
6
|
-
include Child
|
|
7
|
-
|
|
8
|
-
# Raised when a row's SQL is read on a relation that did not resolve it.
|
|
9
|
-
class SqlNotResolved < StandardError; end
|
|
10
|
-
|
|
11
|
-
# The statement, from the row or from its group's shape (QueryShape).
|
|
12
|
-
# A correlated lookup rather than a join: the queries page sorts a
|
|
13
|
-
# window of millions of rows before taking 50, and a LEFT JOIN there
|
|
14
|
-
# was paid per candidate row (47 s against 0.8 s on the 5M-row window).
|
|
15
|
-
SQL = Arel.sql("COALESCE(NULLIF(queries.sql, ''), " \
|
|
16
|
-
"(SELECT query_shapes.sql FROM query_shapes WHERE query_shapes.group_hash = queries.group_hash), '') AS resolved_sql").freeze
|
|
17
|
-
|
|
18
|
-
# Rows with their SQL resolved in the same statement, so a page loaded
|
|
19
|
-
# inside Environment#with_telemetry can be rendered after the block.
|
|
20
|
-
# Replaces the select list: count with count(:all), and pluck
|
|
21
|
-
# resolved_sql, not sql.
|
|
22
|
-
scope :with_sql, -> { select("queries.*", SQL) }
|
|
23
|
-
|
|
24
|
-
# Free-text search over the statement, wherever it is stored.
|
|
25
|
-
scope :matching, ->(text) {
|
|
26
|
-
pattern = "%#{sanitize_sql_like(text)}%"
|
|
27
|
-
where("queries.sql LIKE :pattern ESCAPE '\\' OR queries.name LIKE :pattern ESCAPE '\\' " \
|
|
28
|
-
"OR queries.group_hash IN (SELECT group_hash FROM query_shapes WHERE sql LIKE :pattern ESCAPE '\\')", pattern: pattern)
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
def self.timeline_scope(execution_id)
|
|
32
|
-
in_execution(execution_id).with_sql
|
|
33
|
-
end
|
|
34
|
-
|
|
35
|
-
# Always a String. A row whose text lives on its shape can only answer
|
|
36
|
-
# through with_sql; asking without it is a bug, not an empty statement.
|
|
37
|
-
def sql
|
|
38
|
-
return self[:resolved_sql].to_s if has_attribute?(:resolved_sql)
|
|
39
|
-
|
|
40
|
-
text = self[:sql].to_s
|
|
41
|
-
raise SqlNotResolved, "Telemetry::Query##{id} stores its SQL on its shape; load it through Query.with_sql" if text.empty? && group_hash
|
|
42
|
-
text
|
|
43
|
-
end
|
|
44
|
-
|
|
45
|
-
def as_row
|
|
46
|
-
{ id: id, group_hash: group_hash, sql: sql.first(2_000), name: name, duration: duration_ms.round(3), occurred_at: occurred_at,
|
|
47
|
-
deploy: deploy, execution_id: execution_id, execution_preview: execution_preview, source: source,
|
|
48
|
-
connection: connection, role: role, adapter: adapter, row_count: row_count, tenant: app_tenant, user_ref: user_ref,
|
|
49
|
-
explain: explain.present? }
|
|
50
|
-
end
|
|
51
|
-
|
|
52
|
-
def timeline_label
|
|
53
|
-
sql.to_s.first(120)
|
|
54
|
-
end
|
|
55
|
-
end
|
|
56
|
-
end
|
|
57
|
-
end
|
|
@@ -1,45 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Railwatch
|
|
4
|
-
module Telemetry
|
|
5
|
-
# One query group's normalized statement, stored once. The group hash is
|
|
6
|
-
# the gem's digest of the connection name and that statement, so a row
|
|
7
|
-
# whose own text digests to its group carries the statement and stores
|
|
8
|
-
# "" instead (Ingest::Writer); any other text stays on the row. See
|
|
9
|
-
# docs/architecture.md.
|
|
10
|
-
class QueryShape < TelemetryRecord
|
|
11
|
-
self.primary_key = :group_hash
|
|
12
|
-
|
|
13
|
-
BACKFILL_BATCH = 20_000
|
|
14
|
-
|
|
15
|
-
# Backfill for rows written before this table existed. Files a shape
|
|
16
|
-
# for each group whose earliest or newest row normalizes back to the
|
|
17
|
-
# group hash (a tenant's earliest rows may predate the gem sending
|
|
18
|
-
# normalized text; its newest will not), then blanks every row whose
|
|
19
|
-
# text is its group's shape, in id-range batches so no statement holds
|
|
20
|
-
# the tenant's write lock for long. Idempotent, and never blanks a row
|
|
21
|
-
# whose text is not on file. Returns what it did.
|
|
22
|
-
def self.dedupe_rows!(batch: BACKFILL_BATCH)
|
|
23
|
-
before = count
|
|
24
|
-
file_shapes!
|
|
25
|
-
moved = Query.where.not(sql: "")
|
|
26
|
-
.where("queries.sql = (SELECT s.sql FROM query_shapes s WHERE s.group_hash = queries.group_hash)")
|
|
27
|
-
.in_batches(of: batch, use_ranges: true).update_all(sql: "")
|
|
28
|
-
{ shapes: count - before, rows: moved }
|
|
29
|
-
end
|
|
30
|
-
|
|
31
|
-
def self.file_shapes!
|
|
32
|
-
ends = "SELECT MIN(id) AS id FROM queries WHERE group_hash IS NOT NULL GROUP BY group_hash " \
|
|
33
|
-
"UNION SELECT MAX(id) FROM queries WHERE group_hash IS NOT NULL GROUP BY group_hash"
|
|
34
|
-
candidates = Query.joins("JOIN (#{ends}) ends ON ends.id = queries.id").where.not(sql: "")
|
|
35
|
-
.pluck(:group_hash, :sql, :adapter, :connection)
|
|
36
|
-
shapes = candidates.filter_map do |group_hash, sql, adapter, connection|
|
|
37
|
-
digest, normalized = Railwatch::SqlNormalizer.group_and_normalized(sql, adapter: adapter, connection_name: connection)
|
|
38
|
-
{ group_hash: group_hash, sql: normalized } if digest == group_hash
|
|
39
|
-
end
|
|
40
|
-
shapes.uniq { |shape| shape[:group_hash] }.each_slice(500) { |slice| insert_all(slice) }
|
|
41
|
-
end
|
|
42
|
-
private_class_method :file_shapes!
|
|
43
|
-
end
|
|
44
|
-
end
|
|
45
|
-
end
|
|
@@ -1,69 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Railwatch
|
|
4
|
-
module Telemetry
|
|
5
|
-
# Hourly release health per deploy, recomputed from raw `sessions` rows by
|
|
6
|
-
# ReleaseHealthRollupJob. The deploy *is* the release -- the gem stamps it
|
|
7
|
-
# on every record it ships -- so counting sessions per deploy is all this
|
|
8
|
-
# needs. Sentry's crash-free session rate, crash-free user rate, and
|
|
9
|
-
# adoption, over the same data.
|
|
10
|
-
#
|
|
11
|
-
# A session that spans two hours is counted in both buckets, like every
|
|
12
|
-
# other rollup here: the bucket, not the session, is the unit these numbers
|
|
13
|
-
# sum over. Every method must run inside environment.with_telemetry { }.
|
|
14
|
-
class ReleaseHealth < TelemetryRecord
|
|
15
|
-
self.table_name = "release_health"
|
|
16
|
-
|
|
17
|
-
scope :between, ->(from, to) { where(bucket: from.beginning_of_hour..to) }
|
|
18
|
-
# A nil deploy means "every release in the window", which is what the
|
|
19
|
-
# index page's headline numbers are.
|
|
20
|
-
scope :for_release, ->(deploy) { deploy ? where(deploy: deploy) : all }
|
|
21
|
-
|
|
22
|
-
TOTALS = %i[sessions sessions_errored sessions_crashed users users_crashed duration_sum duration_count].freeze
|
|
23
|
-
|
|
24
|
-
# Window totals for one release. Rates come back nil, not zero, when there
|
|
25
|
-
# is nothing to divide by, so an environment with no sessions yet never
|
|
26
|
-
# renders as "0% crash-free".
|
|
27
|
-
def self.for_deploy(deploy, from, to)
|
|
28
|
-
sessions, errored, crashed, users, users_crashed, duration_sum, duration_count =
|
|
29
|
-
between(from, to).for_release(deploy).pick(*TOTALS.map { |column| Arel.sql("COALESCE(SUM(#{column}), 0)") })
|
|
30
|
-
{
|
|
31
|
-
sessions: sessions, users: users,
|
|
32
|
-
crash_free_sessions: rate(sessions - crashed, sessions),
|
|
33
|
-
crash_free_users: rate(users - users_crashed, users),
|
|
34
|
-
errored: rate(errored, sessions),
|
|
35
|
-
avg_duration_ms: duration_count.zero? ? nil : (duration_sum / duration_count / 1000.0).round(2)
|
|
36
|
-
}
|
|
37
|
-
end
|
|
38
|
-
|
|
39
|
-
# Points for the stacked sessions-by-status chart, one per `step` across
|
|
40
|
-
# the window. The table is hourly and a session is counted once per hour
|
|
41
|
-
# it was seen, so a step under an hour is drawn at an hour: raw session
|
|
42
|
-
# rows would count a live session on every flush.
|
|
43
|
-
def self.series(deploy, from, to, step: nil)
|
|
44
|
-
step = [ Telemetry::Aggregations::STEPS.fetch(step || Telemetry::Aggregations.default_step(from, to)), 1.hour ].max
|
|
45
|
-
bucket = Telemetry::Aggregations.bucket_sql("bucket", step)
|
|
46
|
-
points = between(from, to).for_release(deploy).group(bucket).order(bucket)
|
|
47
|
-
.pluck(bucket, Arel.sql("SUM(sessions)"), Arel.sql("SUM(sessions_errored)"), Arel.sql("SUM(sessions_crashed)"))
|
|
48
|
-
.map { |b, sessions, errored, crashed|
|
|
49
|
-
{ t: Time.at(b).utc, sessions: sessions, ok: sessions - errored - crashed, errored: errored, crashed: crashed }
|
|
50
|
-
}
|
|
51
|
-
Telemetry::Aggregations.fill(points, from, to, step) { |t| { t: t, sessions: 0, ok: 0, errored: 0, crashed: 0 } }
|
|
52
|
-
end
|
|
53
|
-
|
|
54
|
-
# Each release's share of the window's sessions, as a percentage -- how
|
|
55
|
-
# far a new release has rolled out.
|
|
56
|
-
def self.adoption(from, to)
|
|
57
|
-
counts = between(from, to).group(:deploy).sum(:sessions)
|
|
58
|
-
total = counts.values.sum
|
|
59
|
-
return counts.transform_values { 0.0 } if total.zero?
|
|
60
|
-
counts.transform_values { |n| (n * 100.0 / total).round(1) }
|
|
61
|
-
end
|
|
62
|
-
|
|
63
|
-
def self.rate(numerator, denominator)
|
|
64
|
-
return nil if denominator.to_i.zero?
|
|
65
|
-
(numerator * 100.0 / denominator).round(2)
|
|
66
|
-
end
|
|
67
|
-
end
|
|
68
|
-
end
|
|
69
|
-
end
|
|
@@ -1,157 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Railwatch
|
|
4
|
-
require "tdigest"
|
|
5
|
-
|
|
6
|
-
module Telemetry
|
|
7
|
-
# Hourly aggregate per (record_type, group). Percentiles are exact within
|
|
8
|
-
# an hour and merged across hours with t-digest, so a 30-day p95 for a
|
|
9
|
-
# route is one query over 720 small rows.
|
|
10
|
-
class Rollup < TelemetryRecord
|
|
11
|
-
scope :for_type, ->(type) { where(record_type: type) }
|
|
12
|
-
scope :between, ->(from, to) { where(bucket: from.beginning_of_hour..to) }
|
|
13
|
-
|
|
14
|
-
def self.bucket_for(time)
|
|
15
|
-
time.utc.beginning_of_hour
|
|
16
|
-
end
|
|
17
|
-
|
|
18
|
-
# Merge a list of duration samples (microseconds) into the bucket.
|
|
19
|
-
def self.absorb!(record_type:, group_hash:, name:, bucket:, durations:, errors: 0, client_errors: 0, extra: {})
|
|
20
|
-
return if durations.empty?
|
|
21
|
-
row = find_or_initialize_by(record_type: record_type, group_hash: group_hash, bucket: bucket)
|
|
22
|
-
row.name = name
|
|
23
|
-
digest = row.digest ? TDigest::TDigest.from_bytes(row.digest) : TDigest::TDigest.new(0.01)
|
|
24
|
-
durations.each { |d| digest.push(d) }
|
|
25
|
-
digest.compress!
|
|
26
|
-
row.count += durations.size
|
|
27
|
-
row.error_count += errors
|
|
28
|
-
row.client_error_count += client_errors
|
|
29
|
-
row.duration_sum += durations.sum
|
|
30
|
-
row.duration_max = [ row.duration_max, durations.max ].max
|
|
31
|
-
row.p50 = digest.percentile(0.5).to_i
|
|
32
|
-
row.p95 = digest.percentile(0.95).to_i
|
|
33
|
-
row.p99 = digest.percentile(0.99).to_i
|
|
34
|
-
row.digest = digest.as_small_bytes
|
|
35
|
-
row.extra = row.extra.merge(extra) { |_k, a, b| a.is_a?(Numeric) && b.is_a?(Numeric) ? a + b : b }
|
|
36
|
-
row.save!
|
|
37
|
-
row
|
|
38
|
-
end
|
|
39
|
-
|
|
40
|
-
# The row absorb! would write over an empty bucket, as attributes for
|
|
41
|
-
# insert_all. RollupJob deletes an hour's rows and writes every group in
|
|
42
|
-
# one statement, so the read absorb! does per group is wasted there.
|
|
43
|
-
def self.fresh_attributes(record_type:, group_hash:, name:, bucket:, durations:, errors: 0, client_errors: 0, extra: {})
|
|
44
|
-
digest = TDigest::TDigest.new(0.01)
|
|
45
|
-
durations.each { |d| digest.push(d) }
|
|
46
|
-
digest.compress!
|
|
47
|
-
{ record_type: record_type, group_hash: group_hash, name: name, bucket: bucket,
|
|
48
|
-
count: durations.size, error_count: errors, client_error_count: client_errors,
|
|
49
|
-
duration_sum: durations.sum, duration_max: durations.max,
|
|
50
|
-
p50: digest.percentile(0.5).to_i, p95: digest.percentile(0.95).to_i, p99: digest.percentile(0.99).to_i,
|
|
51
|
-
digest: digest.as_small_bytes, extra: extra }
|
|
52
|
-
end
|
|
53
|
-
|
|
54
|
-
# Aggregate a relation of rollups into one summary with merged percentiles.
|
|
55
|
-
def self.summarize(relation)
|
|
56
|
-
rows = relation.to_a
|
|
57
|
-
return { count: 0, errors: 0, client_errors: 0, avg: 0, p50: 0, p95: 0, p99: 0, max: 0, extra: {} } if rows.empty?
|
|
58
|
-
p50, p95, p99 = merged_percentiles(rows, [ 0.5, 0.95, 0.99 ])
|
|
59
|
-
count = rows.sum(&:count)
|
|
60
|
-
{
|
|
61
|
-
count: count,
|
|
62
|
-
errors: rows.sum(&:error_count),
|
|
63
|
-
client_errors: rows.sum(&:client_error_count),
|
|
64
|
-
avg: count.zero? ? 0 : (rows.sum(&:duration_sum) / count),
|
|
65
|
-
p50: p50,
|
|
66
|
-
p95: p95,
|
|
67
|
-
p99: p99,
|
|
68
|
-
max: rows.map(&:duration_max).max,
|
|
69
|
-
# Everything a type puts in `extra` -- llm_call's cost_nanos and
|
|
70
|
-
# token counts, cache_event's hits and misses -- merged the way
|
|
71
|
-
# absorb! merges it: numbers add up, anything else is last-wins.
|
|
72
|
-
# Spend is a sum over a window rather than a percentile of
|
|
73
|
-
# durations, so a rule about money has nowhere else to read from.
|
|
74
|
-
extra: merge_extras(rows)
|
|
75
|
-
}
|
|
76
|
-
end
|
|
77
|
-
|
|
78
|
-
# Percentiles of the union of every row's centroids, read straight out
|
|
79
|
-
# of the stored bytes. Merging through TDigest pushed each centroid into
|
|
80
|
-
# a red-black tree one at a time: 2.8 s for a week of the platform's
|
|
81
|
-
# own query rollups (18,674 rows) and 7 s for thirty days, on every
|
|
82
|
-
# cold page load. Sorting the centroids once and walking their
|
|
83
|
-
# cumulative counts answers the same question without re-clustering:
|
|
84
|
-
# 353 ms and 1 s for those two windows. The answer is the first
|
|
85
|
-
# centroid whose running total reaches n * p, which is nearest rank --
|
|
86
|
-
# the rule raw_series uses for sub-hour buckets, so both readings of a
|
|
87
|
-
# window agree. (TDigest#percentile compares each centroid's midpoint
|
|
88
|
-
# instead, which puts the median of 1,000 sevens and 300 nines at 9.)
|
|
89
|
-
def self.merged_percentiles(rows, ps)
|
|
90
|
-
means = []
|
|
91
|
-
counts = []
|
|
92
|
-
rows.each { |row| centroids(row.digest, means, counts) if row.digest }
|
|
93
|
-
return ps.map { 0 } if means.empty?
|
|
94
|
-
|
|
95
|
-
order = (0...means.size).sort_by { |i| means[i] }
|
|
96
|
-
total = counts.sum
|
|
97
|
-
targets = ps.map { |p| total * p }
|
|
98
|
-
found = []
|
|
99
|
-
seen = 0
|
|
100
|
-
order.each do |i|
|
|
101
|
-
seen += counts[i]
|
|
102
|
-
found << means[i] while found.size < targets.size && seen >= targets[found.size]
|
|
103
|
-
break if found.size == targets.size
|
|
104
|
-
end
|
|
105
|
-
found << means[order.last] while found.size < targets.size
|
|
106
|
-
found.map(&:to_i)
|
|
107
|
-
end
|
|
108
|
-
|
|
109
|
-
# Appends one stored digest's centroid means and weights, in either of
|
|
110
|
-
# the tdigest gem's encodings (TDigest.from_bytes reads the same two).
|
|
111
|
-
def self.centroids(bytes, means, counts)
|
|
112
|
-
format, _compression, size = bytes.unpack("LdL")
|
|
113
|
-
case format
|
|
114
|
-
when TDigest::TDigest::VERBOSE_ENCODING
|
|
115
|
-
means.concat(bytes.unpack("@16d#{size}"))
|
|
116
|
-
counts.concat(bytes.unpack("@#{16 + 8 * size}L#{size}"))
|
|
117
|
-
when TDigest::TDigest::SMALL_ENCODING
|
|
118
|
-
# Means are delta-encoded 4-byte floats; weights are 7-bit varints,
|
|
119
|
-
# one byte each unless a centroid holds 128 or more samples.
|
|
120
|
-
mean = 0.0
|
|
121
|
-
bytes.unpack("@16f#{size}").each { |delta| means << (mean += delta) }
|
|
122
|
-
weights = bytes.byteslice(16 + 4 * size, bytes.bytesize).unpack("C*")
|
|
123
|
-
if weights.size == size
|
|
124
|
-
counts.concat(weights)
|
|
125
|
-
else
|
|
126
|
-
at = 0
|
|
127
|
-
size.times do
|
|
128
|
-
byte = weights[at]
|
|
129
|
-
at += 1
|
|
130
|
-
weight = byte & 0x7f
|
|
131
|
-
shift = 7
|
|
132
|
-
while byte & 0x80 != 0
|
|
133
|
-
byte = weights[at] || 0
|
|
134
|
-
at += 1
|
|
135
|
-
weight += (byte & 0x7f) << shift
|
|
136
|
-
shift += 7
|
|
137
|
-
end
|
|
138
|
-
counts << weight
|
|
139
|
-
end
|
|
140
|
-
end
|
|
141
|
-
else
|
|
142
|
-
raise ArgumentError, "unknown t-digest encoding #{format}"
|
|
143
|
-
end
|
|
144
|
-
end
|
|
145
|
-
private_class_method :centroids
|
|
146
|
-
|
|
147
|
-
def self.merge_extras(rows)
|
|
148
|
-
rows.each_with_object({}) do |row, out|
|
|
149
|
-
row.extra.each do |key, value|
|
|
150
|
-
existing = out[key]
|
|
151
|
-
out[key] = existing.is_a?(Numeric) && value.is_a?(Numeric) ? existing + value : value
|
|
152
|
-
end
|
|
153
|
-
end
|
|
154
|
-
end
|
|
155
|
-
end
|
|
156
|
-
end
|
|
157
|
-
end
|
|
@@ -1,24 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Railwatch
|
|
4
|
-
module Telemetry
|
|
5
|
-
# One session of the monitored application, for release health: a browser
|
|
6
|
-
# tab (reported by the gem's beacon client) or a server-side session (the
|
|
7
|
-
# gem's request middleware). The gem re-sends a live session on every flush
|
|
8
|
-
# interval, so the same session_id arrives many times and gets collapsed to
|
|
9
|
-
# one session per bucket by ReleaseHealthRollupJob.
|
|
10
|
-
class Session < TelemetryRecord
|
|
11
|
-
include Child
|
|
12
|
-
|
|
13
|
-
STATUSES = %w[started ok errored crashed].freeze
|
|
14
|
-
|
|
15
|
-
def crashed?
|
|
16
|
-
status == "crashed"
|
|
17
|
-
end
|
|
18
|
-
|
|
19
|
-
def timeline_label
|
|
20
|
-
session_id
|
|
21
|
-
end
|
|
22
|
-
end
|
|
23
|
-
end
|
|
24
|
-
end
|
|
@@ -1,19 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Railwatch
|
|
4
|
-
module Telemetry
|
|
5
|
-
# A custom span recorded with Railwatch.span("name") { }, a child of the
|
|
6
|
-
# request/job/command it ran inside.
|
|
7
|
-
class Span < TelemetryRecord
|
|
8
|
-
include Child
|
|
9
|
-
|
|
10
|
-
scope :failed, -> { where(status: "failed") }
|
|
11
|
-
|
|
12
|
-
# The wire record calls the user payload "attributes"; the column is
|
|
13
|
-
# `payload` because `attributes` is Active Record's own method.
|
|
14
|
-
def timeline_label
|
|
15
|
-
name.to_s.first(120)
|
|
16
|
-
end
|
|
17
|
-
end
|
|
18
|
-
end
|
|
19
|
-
end
|
|
@@ -1,240 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Railwatch
|
|
4
|
-
module Telemetry
|
|
5
|
-
# A tenant of the monitored application, as seen in its telemetry. Not an
|
|
6
|
-
# Active Record table: `app_tenant` is a column on every telemetry row (the
|
|
7
|
-
# gem fills it from Railwatch::Context.current_tenant, which multi-tenant
|
|
8
|
-
# Rails apps get from ActiveRecord::Tenanted / TenantRecord.current_tenant),
|
|
9
|
-
# so a "tenant" only exists as a GROUP BY over the raw tables.
|
|
10
|
-
#
|
|
11
|
-
# Telemetry::Rollup -- what every other page's numbers come from -- is keyed
|
|
12
|
-
# by record type and group_hash only and has no tenant dimension, so all of
|
|
13
|
-
# this is computed from raw rows in the window. Every method must run inside
|
|
14
|
-
# environment.with_telemetry { }.
|
|
15
|
-
class Tenant
|
|
16
|
-
MAX_TENANTS = 200
|
|
17
|
-
# A p95 costs one ordered query per tenant (SQLite has no percentile
|
|
18
|
-
# aggregate), so only the busiest tenants get one; the rest report 0.
|
|
19
|
-
P95_TENANTS = 50
|
|
20
|
-
SPARKLINE_BUCKETS = 12
|
|
21
|
-
# Tenant names are bound one variable each, and SQLite allows 32,766
|
|
22
|
-
# per statement; an app with more tenants than that is asked in slices.
|
|
23
|
-
TENANT_SLICE = 1_000
|
|
24
|
-
SORTS = { "requests" => :requests, "errors" => :errors, "p95" => :p95, "users" => :users }.freeze
|
|
25
|
-
|
|
26
|
-
ERRORS_SQL = Arel.sql("SUM(CASE WHEN status >= 500 THEN 1 ELSE 0 END)")
|
|
27
|
-
CLIENT_ERRORS_SQL = Arel.sql("SUM(CASE WHEN status BETWEEN 400 AND 499 THEN 1 ELSE 0 END)")
|
|
28
|
-
FAILED_SQL = Arel.sql("SUM(CASE WHEN outcome = 'failed' THEN 1 ELSE 0 END)")
|
|
29
|
-
COUNT_SQL = Arel.sql("COUNT(*)")
|
|
30
|
-
USERS_SQL = Arel.sql("COUNT(DISTINCT user_ref)")
|
|
31
|
-
|
|
32
|
-
# One aggregate row per tenant seen in the window, filtered by `q`
|
|
33
|
-
# (a substring of the tenant name), sorted, and capped at MAX_TENANTS.
|
|
34
|
-
def self.index(from, to, q: nil, sort: nil, dir: nil)
|
|
35
|
-
rows = Hash.new { |h, tenant| h[tenant] = blank_row(tenant) }
|
|
36
|
-
absorb_requests(rows, from, to, q)
|
|
37
|
-
absorb_jobs(rows, from, to, q)
|
|
38
|
-
absorb_counts(rows, filtered(Telemetry::Exception.between(from, to), q), :exceptions)
|
|
39
|
-
absorb_counts(rows, filtered(Telemetry::Log.between(from, to), q), :logs)
|
|
40
|
-
absorb_last_seen(rows, from, to, q)
|
|
41
|
-
absorb_sparklines(rows, from, to)
|
|
42
|
-
absorb_p95s(rows.values.max_by(P95_TENANTS) { |r| r[:requests] }, from, to)
|
|
43
|
-
sorted(rows.values, sort, dir)
|
|
44
|
-
end
|
|
45
|
-
|
|
46
|
-
# Headline numbers for the index page, computed from the rows it shows
|
|
47
|
-
# (so the top-tenant share is a share of the listed tenants' requests)
|
|
48
|
-
# plus the share of requests in the window that carry no tenant at all.
|
|
49
|
-
#
|
|
50
|
-
# The untagged count is the window's requests less the tagged ones,
|
|
51
|
-
# and the tagged ones are counted per tenant in app_tenant's index.
|
|
52
|
-
# Counting either side with app_tenant IS [NOT] NULL read every request
|
|
53
|
-
# in the window instead: 1 s on an untagged app, 24 s on a tagged one.
|
|
54
|
-
def self.overview(rows, from, to)
|
|
55
|
-
requests = Telemetry::Execution.requests.between(from, to)
|
|
56
|
-
total = requests.count
|
|
57
|
-
tagged_total = tagged_tenants(from, to).each_slice(TENANT_SLICE).sum { |slice| requests.where(app_tenant: slice).count }
|
|
58
|
-
tagged = rows.sum { |r| r[:requests] }
|
|
59
|
-
top = rows.max_by { |r| r[:requests] }
|
|
60
|
-
{
|
|
61
|
-
tenants: rows.size, top_tenant: top && top[:tenant],
|
|
62
|
-
top_share: tagged.zero? ? 0.0 : (top[:requests] * 100.0 / tagged).round(1),
|
|
63
|
-
with_errors: rows.count { |r| r[:errors].positive? },
|
|
64
|
-
untagged_share: total.zero? ? 0.0 : ((total - tagged_total) * 100.0 / total).round(1)
|
|
65
|
-
}
|
|
66
|
-
end
|
|
67
|
-
|
|
68
|
-
# Every tenant that sent anything in the window, from the tenant-led
|
|
69
|
-
# index (a skip-scan over distinct values, not the window's rows).
|
|
70
|
-
def self.tagged_tenants(from, to)
|
|
71
|
-
Telemetry::Execution.between(from, to).where.not(app_tenant: nil).distinct.pluck(:app_tenant)
|
|
72
|
-
end
|
|
73
|
-
|
|
74
|
-
# Window totals for one tenant. Durations in milliseconds, like the rest
|
|
75
|
-
# of the tenant props.
|
|
76
|
-
def self.summary(tenant, from, to)
|
|
77
|
-
requests = Telemetry::Execution.requests.between(from, to).where(app_tenant: tenant)
|
|
78
|
-
count, errors, users = requests.pick(COUNT_SQL, ERRORS_SQL, USERS_SQL)
|
|
79
|
-
jobs, failed_jobs = Telemetry::Execution.jobs.between(from, to).where(app_tenant: tenant).pick(COUNT_SQL, FAILED_SQL)
|
|
80
|
-
{
|
|
81
|
-
requests: count, errors: errors.to_i, p95: p95_ms(requests, count), jobs: jobs, failed_jobs: failed_jobs.to_i,
|
|
82
|
-
exceptions: Telemetry::Exception.between(from, to).where(app_tenant: tenant).count,
|
|
83
|
-
users: users, logs: Telemetry::Log.between(from, to).where(app_tenant: tenant).count
|
|
84
|
-
}
|
|
85
|
-
end
|
|
86
|
-
|
|
87
|
-
# Request volume and duration bucketed over the window at `step`,
|
|
88
|
-
# SeriesPoint-shaped so the same charts render it. Rollups carry no tenant,
|
|
89
|
-
# so every step reads raw rows (Telemetry::Aggregations.points does that
|
|
90
|
-
# for a tenant), with exact percentiles per bucket.
|
|
91
|
-
def self.series(tenant, from, to, step: nil)
|
|
92
|
-
Telemetry::Aggregations.points("request", from: from, to: to, step: step, tenant: tenant)
|
|
93
|
-
end
|
|
94
|
-
|
|
95
|
-
def self.routes(tenant, from, to, limit: 20)
|
|
96
|
-
grouped_executions(Telemetry::Execution.requests, tenant, from, to, ERRORS_SQL, :errors, limit)
|
|
97
|
-
end
|
|
98
|
-
|
|
99
|
-
def self.job_classes(tenant, from, to, limit: 10)
|
|
100
|
-
grouped_executions(Telemetry::Execution.jobs, tenant, from, to, FAILED_SQL, :failed, limit)
|
|
101
|
-
end
|
|
102
|
-
|
|
103
|
-
def self.exceptions(tenant, from, to, limit: 20)
|
|
104
|
-
Telemetry::Exception.between(from, to).where(app_tenant: tenant).recent.limit(limit).map do |e|
|
|
105
|
-
{ id: e.id, class_name: e.class_name, message: e.message.first(500), occurred_at: e.occurred_at,
|
|
106
|
-
execution_id: e.execution_id, group_hash: e.group_hash }
|
|
107
|
-
end
|
|
108
|
-
end
|
|
109
|
-
|
|
110
|
-
def self.people(tenant, limit: 20)
|
|
111
|
-
Telemetry::Person.where(app_tenant: tenant).recent.limit(limit).map do |person|
|
|
112
|
-
{ ref: person.ref, name: person.display_name, email: person.email, last_seen_at: person.last_seen_at }
|
|
113
|
-
end
|
|
114
|
-
end
|
|
115
|
-
|
|
116
|
-
# The tenant page's "Recent requests" table.
|
|
117
|
-
def self.recent_requests(tenant, from, to, limit: 50)
|
|
118
|
-
Telemetry::Execution.requests.between(from, to).where(app_tenant: tenant).recent.limit(limit).map(&:as_row)
|
|
119
|
-
end
|
|
120
|
-
|
|
121
|
-
# -- Aggregation steps -----------------------------------------------------
|
|
122
|
-
|
|
123
|
-
# The per-tenant sums read through idx_executions_tenant_summary, which
|
|
124
|
-
# holds every column they need for tagged rows only. SQLite prefers the
|
|
125
|
-
# narrower (app_tenant, occurred_at) index on its own estimate and then
|
|
126
|
-
# fetches every row from the table (8 s against 131 ms over 30 days).
|
|
127
|
-
def self.summary_scope(kind)
|
|
128
|
-
scope = Telemetry::Execution
|
|
129
|
-
scope = scope.from("#{scope.quoted_table_name} INDEXED BY idx_executions_tenant_summary") if TelemetryRecord.sqlite?
|
|
130
|
-
scope.where(kind: kind).where.not(app_tenant: nil)
|
|
131
|
-
end
|
|
132
|
-
|
|
133
|
-
def self.absorb_requests(rows, from, to, q)
|
|
134
|
-
filtered(summary_scope("request").between(from, to), q).group(:app_tenant)
|
|
135
|
-
.pluck(:app_tenant, COUNT_SQL, ERRORS_SQL, Arel.sql("AVG(duration)"), Arel.sql("MAX(duration)"), USERS_SQL)
|
|
136
|
-
.each do |tenant, count, errors, avg, max, users|
|
|
137
|
-
rows[tenant].merge!(requests: count, errors: errors, avg: ms(avg), max: ms(max), users: users)
|
|
138
|
-
end
|
|
139
|
-
end
|
|
140
|
-
|
|
141
|
-
def self.absorb_jobs(rows, from, to, q)
|
|
142
|
-
filtered(summary_scope("job_attempt").between(from, to), q).group(:app_tenant)
|
|
143
|
-
.pluck(:app_tenant, COUNT_SQL, FAILED_SQL)
|
|
144
|
-
.each { |tenant, count, failed| rows[tenant].merge!(jobs: count, failed_jobs: failed) }
|
|
145
|
-
end
|
|
146
|
-
|
|
147
|
-
def self.absorb_counts(rows, scope, key)
|
|
148
|
-
scope.group(:app_tenant).count.each { |tenant, count| rows[tenant][key] = count }
|
|
149
|
-
end
|
|
150
|
-
|
|
151
|
-
def self.absorb_last_seen(rows, from, to, q)
|
|
152
|
-
filtered(summary_scope(Telemetry::Execution::KINDS).between(from, to), q).group(:app_tenant).maximum(:occurred_at)
|
|
153
|
-
.each { |tenant, at| rows[tenant][:last_seen_at] = at }
|
|
154
|
-
end
|
|
155
|
-
|
|
156
|
-
# Only for the tenants already found, by name: grouped by tenant and a
|
|
157
|
-
# time expression SQLite would not seek the app_tenant index and read
|
|
158
|
-
# every request in the window instead -- 11 s over a week on an app
|
|
159
|
-
# with no tenants at all, to draw nothing.
|
|
160
|
-
def self.absorb_sparklines(rows, from, to)
|
|
161
|
-
tenants = rows.keys.compact
|
|
162
|
-
return if tenants.empty?
|
|
163
|
-
|
|
164
|
-
width = bucket_width(from, to, SPARKLINE_BUCKETS)
|
|
165
|
-
counts = tenants.each_slice(TENANT_SLICE).flat_map do |slice|
|
|
166
|
-
Telemetry::Execution.requests.between(from, to).where(app_tenant: slice)
|
|
167
|
-
.group(:app_tenant, bucket_sql(from, width)).count.to_a
|
|
168
|
-
end
|
|
169
|
-
counts.each do |(tenant, index), count|
|
|
170
|
-
rows[tenant][:sparkline][[ index.to_i, SPARKLINE_BUCKETS - 1 ].min] += count
|
|
171
|
-
end
|
|
172
|
-
end
|
|
173
|
-
|
|
174
|
-
def self.absorb_p95s(rows, from, to)
|
|
175
|
-
rows.each do |row|
|
|
176
|
-
scope = Telemetry::Execution.requests.between(from, to).where(app_tenant: row[:tenant])
|
|
177
|
-
row[:p95] = p95_ms(scope, row[:requests])
|
|
178
|
-
end
|
|
179
|
-
end
|
|
180
|
-
|
|
181
|
-
# -- Helpers ---------------------------------------------------------------
|
|
182
|
-
|
|
183
|
-
def self.blank_row(tenant)
|
|
184
|
-
{ tenant: tenant, requests: 0, errors: 0, avg: 0.0, max: 0.0, p95: 0.0, jobs: 0, failed_jobs: 0,
|
|
185
|
-
exceptions: 0, logs: 0, users: 0, last_seen_at: nil, sparkline: Array.new(SPARKLINE_BUCKETS, 0) }
|
|
186
|
-
end
|
|
187
|
-
|
|
188
|
-
# Tenant-tagged rows only: a NULL app_tenant never matches LIKE either.
|
|
189
|
-
def self.filtered(scope, q)
|
|
190
|
-
return scope.where.not(app_tenant: nil) if q.blank?
|
|
191
|
-
scope.where("app_tenant LIKE ?", "%#{TelemetryRecord.sanitize_sql_like(q.to_s)}%")
|
|
192
|
-
end
|
|
193
|
-
|
|
194
|
-
def self.sorted(rows, sort, dir)
|
|
195
|
-
key = SORTS.fetch(sort.to_s, :requests)
|
|
196
|
-
sign = dir.to_s == "asc" ? 1 : -1
|
|
197
|
-
rows.sort_by { |row| row[key] * sign }.first(MAX_TENANTS)
|
|
198
|
-
end
|
|
199
|
-
|
|
200
|
-
# The 95th percentile as the duration `count * 5%` rows from the slowest
|
|
201
|
-
# end -- one indexed query, no percentile function needed.
|
|
202
|
-
def self.p95_ms(scope, count)
|
|
203
|
-
return 0.0 if count.to_i.zero?
|
|
204
|
-
ms(scope.order(duration: :desc).offset((count * 0.05).floor).limit(1).pluck(:duration).first)
|
|
205
|
-
end
|
|
206
|
-
|
|
207
|
-
def self.grouped_executions(scope, tenant, from, to, error_sql, error_key, limit)
|
|
208
|
-
scope.between(from, to).where(app_tenant: tenant).group(:group_hash, :name)
|
|
209
|
-
.pluck(:group_hash, :name, COUNT_SQL, error_sql, Arel.sql("AVG(duration)"), Arel.sql("MAX(duration)"))
|
|
210
|
-
.map { |group_hash, name, count, errors, avg, max|
|
|
211
|
-
{ group_hash: group_hash, name: name, count: count, error_key => errors, avg: ms(avg), max: ms(max) }
|
|
212
|
-
}.sort_by { |row| -row[:count] }.first(limit)
|
|
213
|
-
end
|
|
214
|
-
|
|
215
|
-
# Microseconds on the wire, milliseconds in every prop.
|
|
216
|
-
def self.ms(duration)
|
|
217
|
-
duration ? (duration / 1000.0).round(2) : 0.0
|
|
218
|
-
end
|
|
219
|
-
|
|
220
|
-
def self.bucket_width(from, to, buckets)
|
|
221
|
-
[ (to - from) / buckets, 1.0 ].max
|
|
222
|
-
end
|
|
223
|
-
|
|
224
|
-
# Bucket index per row: epoch seconds since `from` over the bucket width.
|
|
225
|
-
# Windows are an hour or more, so second resolution is plenty.
|
|
226
|
-
# Both interpolations are coerced to numbers first (`from` is a Time, `width`
|
|
227
|
-
# a Float from bucket_width), so the fragment can only ever contain two
|
|
228
|
-
# numeric literals -- never a caller-supplied string.
|
|
229
|
-
def self.bucket_sql(from, width)
|
|
230
|
-
Arel.sql("CAST((strftime('%s', occurred_at) - #{Integer(from.to_i)}) / #{Float(width)} AS INTEGER)")
|
|
231
|
-
end
|
|
232
|
-
|
|
233
|
-
# A row landing exactly on `to` indexes one bucket past the end; clamp it
|
|
234
|
-
# into the last one.
|
|
235
|
-
def self.bucket_at(from, width, index, buckets)
|
|
236
|
-
from + ([ index.to_i, buckets - 1 ].min * width)
|
|
237
|
-
end
|
|
238
|
-
end
|
|
239
|
-
end
|
|
240
|
-
end
|