railwatch 0.1.4 → 0.2.0.pre1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +205 -0
- data/README.md +10 -0
- data/app/channels/railwatch/environment_channel.rb +28 -0
- data/app/controllers/concerns/railwatch/telemetry_identity.rb +25 -0
- data/app/controllers/railwatch/alerts_controller.rb +48 -0
- data/app/controllers/railwatch/anomaly_rules_controller.rb +31 -0
- data/app/controllers/railwatch/attachments_controller.rb +22 -0
- data/app/controllers/railwatch/beacon_controller.rb +67 -7
- data/app/controllers/railwatch/broadcasts_controller.rb +15 -0
- data/app/controllers/railwatch/cache_events_controller.rb +30 -0
- data/app/controllers/railwatch/commands_controller.rb +16 -0
- data/app/controllers/railwatch/comments_controller.rb +11 -0
- data/app/controllers/railwatch/dashboard_controller.rb +69 -0
- data/app/controllers/railwatch/deploys_controller.rb +34 -0
- data/app/controllers/railwatch/deprecations_controller.rb +54 -0
- data/app/controllers/railwatch/environment_scoped.rb +99 -0
- data/app/controllers/railwatch/exceptions_controller.rb +53 -0
- data/app/controllers/railwatch/executions_controller.rb +13 -0
- data/app/controllers/railwatch/issues_controller.rb +258 -0
- data/app/controllers/railwatch/jobs_controller.rb +63 -0
- data/app/controllers/railwatch/llm_calls_controller.rb +124 -0
- data/app/controllers/railwatch/logs_controller.rb +40 -0
- data/app/controllers/railwatch/mails_controller.rb +19 -0
- data/app/controllers/railwatch/notifications_controller.rb +11 -0
- data/app/controllers/railwatch/outgoing_requests_controller.rb +19 -0
- data/app/controllers/railwatch/overview_controller.rb +38 -0
- data/app/controllers/railwatch/people_controller.rb +25 -0
- data/app/controllers/railwatch/processes_controller.rb +33 -0
- data/app/controllers/railwatch/profiles_controller.rb +50 -0
- data/app/controllers/railwatch/queries_controller.rb +59 -0
- data/app/controllers/railwatch/releases_controller.rb +75 -0
- data/app/controllers/railwatch/requests_controller.rb +55 -0
- data/app/controllers/railwatch/saved_views_controller.rb +53 -0
- data/app/controllers/railwatch/scheduled_tasks_controller.rb +45 -0
- data/app/controllers/railwatch/spans_controller.rb +47 -0
- data/app/controllers/railwatch/storage_ops_controller.rb +15 -0
- data/app/controllers/railwatch/tenants_controller.rb +44 -0
- data/app/controllers/railwatch/thresholds_controller.rb +40 -0
- data/app/controllers/railwatch/traces_controller.rb +72 -0
- data/app/controllers/railwatch/transactions_controller.rb +21 -0
- data/app/controllers/railwatch/view_renders_controller.rb +28 -0
- data/app/controllers/railwatch/visits_controller.rb +53 -0
- data/app/helpers/railwatch/assets_helper.rb +52 -0
- data/app/jobs/railwatch/anomaly_scan_job.rb +11 -0
- data/app/jobs/railwatch/application_job.rb +7 -0
- data/app/jobs/railwatch/auto_resolve_issues_job.rb +19 -0
- data/app/jobs/railwatch/check_scheduled_tasks_job.rb +113 -0
- data/app/jobs/railwatch/detect_anomalies_job.rb +171 -0
- data/app/jobs/railwatch/detect_performance_issues_job.rb +85 -0
- data/app/jobs/railwatch/group_exceptions_job.rb +91 -0
- data/app/jobs/railwatch/optimize_telemetry_job.rb +23 -0
- data/app/jobs/railwatch/performance_scan_job.rb +11 -0
- data/app/jobs/railwatch/prune_telemetry_job.rb +110 -0
- data/app/jobs/railwatch/release_health_rollup_job.rb +64 -0
- data/app/jobs/railwatch/rollup_catchup_job.rb +21 -0
- data/app/jobs/railwatch/rollup_job.rb +130 -0
- data/app/jobs/railwatch/scheduled_task_scan_job.rb +11 -0
- data/app/models/concerns/railwatch/detection_snapshotting.rb +20 -0
- data/app/models/railwatch/alert.rb +229 -0
- data/app/models/railwatch/alert_rule.rb +121 -0
- data/app/models/railwatch/anomaly_rule.rb +33 -0
- data/app/models/railwatch/application.rb +44 -0
- data/app/models/railwatch/application_record.rb +33 -0
- data/app/models/railwatch/comment.rb +36 -0
- data/app/models/railwatch/deploy.rb +67 -0
- data/app/models/railwatch/environment.rb +54 -0
- data/app/models/railwatch/execution_presenter.rb +185 -0
- data/app/models/railwatch/filter_query.rb +143 -0
- data/app/models/railwatch/followup_receipt.rb +27 -0
- data/app/models/railwatch/ingest/batch.rb +305 -0
- data/app/models/railwatch/ingest/mapper.rb +575 -0
- data/app/models/railwatch/ingest/payload.rb +96 -0
- data/app/models/railwatch/ingest/rollup_absorber.rb +170 -0
- data/app/models/railwatch/ingest/writer.rb +137 -0
- data/app/models/railwatch/issue.rb +277 -0
- data/app/models/railwatch/issue_activity.rb +23 -0
- data/app/models/railwatch/issue_detection_presenter.rb +309 -0
- data/app/models/railwatch/issue_detection_snapshot.rb +77 -0
- data/app/models/railwatch/maintenance_task.rb +53 -0
- data/app/models/railwatch/saved_view.rb +63 -0
- data/app/models/railwatch/telemetry/aggregations.rb +116 -0
- data/app/models/railwatch/telemetry/attachment.rb +31 -0
- data/app/models/railwatch/telemetry/bounded_gzip.rb +101 -0
- data/app/models/railwatch/telemetry/broadcast.rb +13 -0
- data/app/models/railwatch/telemetry/cache_event.rb +16 -0
- data/app/models/railwatch/telemetry/child.rb +53 -0
- data/app/models/railwatch/telemetry/cursor_page.rb +99 -0
- data/app/models/railwatch/telemetry/deprecation.rb +13 -0
- data/app/models/railwatch/telemetry/enqueued_job.rb +13 -0
- data/app/models/railwatch/telemetry/exception.rb +21 -0
- data/app/models/railwatch/telemetry/execution.rb +71 -0
- data/app/models/railwatch/telemetry/health_sample.rb +72 -0
- data/app/models/railwatch/telemetry/ingest_batch.rb +31 -0
- data/app/models/railwatch/telemetry/llm_call.rb +37 -0
- data/app/models/railwatch/telemetry/log.rb +85 -0
- data/app/models/railwatch/telemetry/mail.rb +13 -0
- data/app/models/railwatch/telemetry/n_plus_one.rb +92 -0
- data/app/models/railwatch/telemetry/notification.rb +13 -0
- data/app/models/railwatch/telemetry/outgoing_request.rb +13 -0
- data/app/models/railwatch/telemetry/person.rb +50 -0
- data/app/models/railwatch/telemetry/process.rb +10 -0
- data/app/models/railwatch/telemetry/profile.rb +37 -0
- data/app/models/railwatch/telemetry/query.rb +50 -0
- data/app/models/railwatch/telemetry/query_shape.rb +45 -0
- data/app/models/railwatch/telemetry/release_health.rb +63 -0
- data/app/models/railwatch/telemetry/rollup.rb +78 -0
- data/app/models/railwatch/telemetry/session.rb +24 -0
- data/app/models/railwatch/telemetry/span.rb +19 -0
- data/app/models/railwatch/telemetry/storage_op.rb +13 -0
- data/app/models/railwatch/telemetry/tenant.rb +221 -0
- data/app/models/railwatch/telemetry/transaction.rb +13 -0
- data/app/models/railwatch/telemetry/view_render.rb +13 -0
- data/app/models/railwatch/telemetry/visit.rb +24 -0
- data/app/models/railwatch/telemetry_record.rb +57 -0
- data/app/models/railwatch/threshold.rb +29 -0
- data/app/models/railwatch/user.rb +47 -0
- data/app/models/railwatch/viewer.rb +13 -0
- data/app/views/layouts/railwatch/dashboard.html.erb +25 -0
- data/config/routes.rb +59 -0
- data/db/railwatch_migrate/20260916000000_create_railwatch_tables.rb +151 -0
- data/db/railwatch_migrate/20260917000000_create_railwatch_maintenance_tasks.rb +18 -0
- data/db/railwatch_migrate/20260917120000_create_railwatch_followup_receipts.rb +20 -0
- data/db/railwatch_telemetry_migrate/20260903000001_create_telemetry.rb +481 -0
- data/db/railwatch_telemetry_migrate/20260903000002_rename_tenant_to_app_tenant.rb +14 -0
- data/db/railwatch_telemetry_migrate/20260903000003_add_statement_count_to_transactions.rb +9 -0
- data/db/railwatch_telemetry_migrate/20260903000004_add_role_and_channel.rb +10 -0
- data/db/railwatch_telemetry_migrate/20260903000005_add_locals_to_exceptions.rb +9 -0
- data/db/railwatch_telemetry_migrate/20260903000006_add_spans_health_vitals_and_fts.rb +82 -0
- data/db/railwatch_telemetry_migrate/20260903000007_rename_span_attributes_to_payload.rb +10 -0
- data/db/railwatch_telemetry_migrate/20260903000008_add_profiles_and_attachments.rb +60 -0
- data/db/railwatch_telemetry_migrate/20260903000009_add_truncated_to_attachments.rb +9 -0
- data/db/railwatch_telemetry_migrate/20260903000010_create_sessions_and_release_health.rb +51 -0
- data/db/railwatch_telemetry_migrate/20260903000011_add_fingerprint_to_exceptions.rb +11 -0
- data/db/railwatch_telemetry_migrate/20260904000012_add_failed_to_broadcasts.rb +10 -0
- data/db/railwatch_telemetry_migrate/20260904010000_add_filter_cursor_indexes.rb +37 -0
- data/db/railwatch_telemetry_migrate/20260904020000_add_n_plus_ones_execution_id_index.rb +11 -0
- data/db/railwatch_telemetry_migrate/20260904120000_create_query_shapes.rb +15 -0
- data/db/railwatch_telemetry_migrate/20260906120000_add_backpressure_factor_to_ingest_batches.rb +9 -0
- data/db/railwatch_telemetry_migrate/20260907000000_rename_lantern_version_on_processes.rb +10 -0
- data/db/railwatch_telemetry_migrate/20260913000000_rename_nightrail_version_on_processes.rb +9 -0
- data/db/railwatch_telemetry_migrate/20260914000000_drop_orphan_durable_ingest_tables.rb +18 -0
- data/db/railwatch_telemetry_migrate/20260914010000_drop_orphan_durable_ingest_columns.rb +25 -0
- data/db/railwatch_telemetry_migrate/20260915000000_create_llm_calls.rb +55 -0
- data/db/railwatch_telemetry_migrate/20260915120000_add_detail_to_llm_calls.rb +21 -0
- data/db/railwatch_telemetry_migrate/20260915200000_add_explained_index_to_queries.rb +16 -0
- data/db/railwatch_telemetry_migrate/20260915210000_add_slowest_index_to_queries.rb +18 -0
- data/db/railwatch_telemetry_migrate/20260917010000_add_batch_ledger_to_ingest_batches.rb +16 -0
- data/docs/configuration.md +26 -0
- data/docs/embedded.md +342 -0
- data/docs/getting-started.md +5 -0
- data/lib/generators/railwatch/install/install_generator.rb +222 -1
- data/lib/generators/railwatch/install/templates/{initializer.rb → initializer.rb.tt} +39 -0
- data/lib/generators/railwatch/install/templates/post-deploy +8 -0
- data/lib/puma/plugin/railwatch.rb +170 -0
- data/lib/railwatch/authentication.rb +83 -0
- data/lib/railwatch/configuration.rb +134 -3
- data/lib/railwatch/dashboard_assets.rb +45 -0
- data/lib/railwatch/embedded.rb +51 -0
- data/lib/railwatch/engine.rb +89 -1
- data/lib/railwatch/ingest_request_body_limit.rb +10 -0
- data/lib/railwatch/json_compat.rb +60 -0
- data/lib/railwatch/maintenance.rb +183 -0
- data/lib/railwatch/patches/runner_command.rb +21 -1
- data/lib/railwatch/record.rb +33 -8
- data/lib/railwatch/reporter.rb +16 -0
- data/lib/railwatch/subscribers/process_info.rb +2 -1
- data/lib/railwatch/transport/local.rb +78 -0
- data/lib/railwatch/transport/socket.rb +183 -0
- data/lib/railwatch/version.rb +1 -1
- data/lib/railwatch/writer.rb +370 -0
- data/lib/railwatch.rb +38 -1
- data/lib/tasks/railwatch_tasks.rake +128 -0
- data/public/railwatch/assets/CommitMono-Bold-D6h61ieg.woff2 +0 -0
- data/public/railwatch/assets/CommitMono-Regular-zr8w7Obm.woff2 +0 -0
- data/public/railwatch/assets/Roboto-Black-auA4GeOK.woff2 +0 -0
- data/public/railwatch/assets/Roboto-Bold-CJLnO8j1.woff2 +0 -0
- data/public/railwatch/assets/Roboto-Medium-Cm2bwKpj.woff2 +0 -0
- data/public/railwatch/assets/Roboto-Regular-Chaq1-PV.woff2 +0 -0
- data/public/railwatch/assets/app-layout-yh-sPWgK.js +1 -0
- data/public/railwatch/assets/app-wordmark-nbjkzxwQ.js +1 -0
- data/public/railwatch/assets/appearance-CDuRvQTB.js +1 -0
- data/public/railwatch/assets/application-C_kpBdnf.css +1 -0
- data/public/railwatch/assets/arrow-up-DVtOGdVA.js +1 -0
- data/public/railwatch/assets/auth-layout-TCwPpS1K.js +1 -0
- data/public/railwatch/assets/badge-Daw4Hvr8.js +1 -0
- data/public/railwatch/assets/braces-DhFbHPsz.js +1 -0
- data/public/railwatch/assets/card-BX_3HXcJ.js +1 -0
- data/public/railwatch/assets/chart-B14-N9g7.js +39 -0
- data/public/railwatch/assets/chart-hover-Vy52H4uD.js +1 -0
- data/public/railwatch/assets/chart-panel-Cb4S_ej_.js +1 -0
- data/public/railwatch/assets/checkbox-D3aRSsBj.js +1 -0
- data/public/railwatch/assets/code-KvW8k7Jr.js +1 -0
- data/public/railwatch/assets/copy-DaQMJWoT.js +1 -0
- data/public/railwatch/assets/copy-block-BKFGd11J.js +1 -0
- data/public/railwatch/assets/copy-id-Djks1fXB.js +1 -0
- data/public/railwatch/assets/cursor-load-more-BXV0f1D_.js +1 -0
- data/public/railwatch/assets/data-table-iv1bdF6u.js +1 -0
- data/public/railwatch/assets/edit-BZc_Iawe.js +1 -0
- data/public/railwatch/assets/edit-DMKUF8Zi.js +8 -0
- data/public/railwatch/assets/edit-__9yJlO3.js +1 -0
- data/public/railwatch/assets/empty-state-6j_0AaQQ.js +1 -0
- data/public/railwatch/assets/env-layout-DrT8rO6P.js +1 -0
- data/public/railwatch/assets/execution-path-CzgBUi5e.js +1 -0
- data/public/railwatch/assets/filter-bar-9SU5NrzX.js +1 -0
- data/public/railwatch/assets/flamegraph-qcekju8V.js +2 -0
- data/public/railwatch/assets/format-B9SDkrWj.js +1 -0
- data/public/railwatch/assets/frames-Cyu7KMxZ.js +1 -0
- data/public/railwatch/assets/google-sign-in-button-DsTSfmzY.js +1 -0
- data/public/railwatch/assets/index-1ol1-QWI.js +1 -0
- data/public/railwatch/assets/index-5jI4aFzC.js +1 -0
- data/public/railwatch/assets/index-9KTrVnrc.js +1 -0
- data/public/railwatch/assets/index-B0-8lcTp.js +1 -0
- data/public/railwatch/assets/index-B7jjfNfO.js +1 -0
- data/public/railwatch/assets/index-BBchRy0M.js +1 -0
- data/public/railwatch/assets/index-BRiq3SNR.js +1 -0
- data/public/railwatch/assets/index-BgKj9xhr.js +1 -0
- data/public/railwatch/assets/index-BkTZqqOu.js +1 -0
- data/public/railwatch/assets/index-BprKx8QO.js +1 -0
- data/public/railwatch/assets/index-C3jzvPs3.js +1 -0
- data/public/railwatch/assets/index-Cbs6gGyQ.js +1 -0
- data/public/railwatch/assets/index-CdRZ6AWF.js +1 -0
- data/public/railwatch/assets/index-CeYKnapu.js +1 -0
- data/public/railwatch/assets/index-Cmlwy1-V.js +1 -0
- data/public/railwatch/assets/index-Cwx6058d.js +1 -0
- data/public/railwatch/assets/index-D4CSdbHv.js +1 -0
- data/public/railwatch/assets/index-DEFMSkdG.js +1 -0
- data/public/railwatch/assets/index-DFiHEBSh.js +1 -0
- data/public/railwatch/assets/index-DL4vWdWJ.js +1 -0
- data/public/railwatch/assets/index-DU9F5b5d.js +1 -0
- data/public/railwatch/assets/index-DaXgPcGL.js +1 -0
- data/public/railwatch/assets/index-DbtaU-EE.js +1 -0
- data/public/railwatch/assets/index-DeOe83F4.js +1 -0
- data/public/railwatch/assets/index-DiucHN4B.js +1 -0
- data/public/railwatch/assets/index-DlnR_l9o.js +1 -0
- data/public/railwatch/assets/index-DlumCsWY.js +2 -0
- data/public/railwatch/assets/index-DmRd7aIG.js +1 -0
- data/public/railwatch/assets/index-DxSh2UpM.js +1 -0
- data/public/railwatch/assets/index-MIMGuFNt.js +1 -0
- data/public/railwatch/assets/index-OqI59zPb.js +1 -0
- data/public/railwatch/assets/index-P4rC7IlX.js +1 -0
- data/public/railwatch/assets/index-gpPOcFWq.js +1 -0
- data/public/railwatch/assets/index-oVkururr.js +1 -0
- data/public/railwatch/assets/index-p9puqVge.js +1 -0
- data/public/railwatch/assets/inertia-TViv6kNv.js +97 -0
- data/public/railwatch/assets/input-error-LxImUkxv.js +1 -0
- data/public/railwatch/assets/json-viewer-Ar4cjPDW.js +1 -0
- data/public/railwatch/assets/klass-CJ-J4INB.js +1 -0
- data/public/railwatch/assets/label-COUKWqE_.js +1 -0
- data/public/railwatch/assets/layout-DNSLAkw_.js +1 -0
- data/public/railwatch/assets/live-dot-ChfUtY3p.js +41 -0
- data/public/railwatch/assets/nav-CNnDqPlm.js +1 -0
- data/public/railwatch/assets/new-84S8ZJq9.js +1 -0
- data/public/railwatch/assets/new-Be55nmt9.js +1 -0
- data/public/railwatch/assets/new-Bi_xQiIb.js +1 -0
- data/public/railwatch/assets/new-BvCT8TMg.js +1 -0
- data/public/railwatch/assets/new-D05SajFR.js +1 -0
- data/public/railwatch/assets/new-D4uewYC8.js +1 -0
- data/public/railwatch/assets/onboarding-CYZi5Cqc.js +1 -0
- data/public/railwatch/assets/origin-identity-6q1-CBts.js +1 -0
- data/public/railwatch/assets/percentile-picker-gFZCXtdb.js +1 -0
- data/public/railwatch/assets/relative-time-IOOgl5n2.js +1 -0
- data/public/railwatch/assets/release-health-DC8oc7uw.js +1 -0
- data/public/railwatch/assets/route-Dv6LAWvT.js +1 -0
- data/public/railwatch/assets/segmented-h1VdDTqE.js +1 -0
- data/public/railwatch/assets/select-_AJsUa7X.js +1 -0
- data/public/railwatch/assets/separator-BwwTYtCF.js +1 -0
- data/public/railwatch/assets/series-chart-DaFPefku.js +1 -0
- data/public/railwatch/assets/show-B7NCgkEo.js +1 -0
- data/public/railwatch/assets/show-BKqyKjBK.js +1 -0
- data/public/railwatch/assets/show-BM6X2Mpo.js +1 -0
- data/public/railwatch/assets/show-BNw4tN5q.js +1 -0
- data/public/railwatch/assets/show-BO3bnG5h.js +1 -0
- data/public/railwatch/assets/show-BhrAVAEA.js +1 -0
- data/public/railwatch/assets/show-C4Ltf5i9.js +2 -0
- data/public/railwatch/assets/show-C8sHalnw.js +1 -0
- data/public/railwatch/assets/show-CeTL4B37.js +2 -0
- data/public/railwatch/assets/show-CpfgV1jP.js +1 -0
- data/public/railwatch/assets/show-DACku6AD.js +3 -0
- data/public/railwatch/assets/show-DIOSGcXV.js +6 -0
- data/public/railwatch/assets/show-DQp_1n-B.js +1 -0
- data/public/railwatch/assets/show-DVNz46RI.js +1 -0
- data/public/railwatch/assets/show-DYteoYWW.js +1 -0
- data/public/railwatch/assets/show-DgSIoRvA.js +1 -0
- data/public/railwatch/assets/show-JxFtB4eK.js +2 -0
- data/public/railwatch/assets/sort-header-DpFzXblu.js +1 -0
- data/public/railwatch/assets/source-link-B2183i2-.js +1 -0
- data/public/railwatch/assets/sparkline-cell-C3-5vFkP.js +1 -0
- data/public/railwatch/assets/stat-s4RpOS9w.js +1 -0
- data/public/railwatch/assets/status-badge-8jVV-LA4.js +1 -0
- data/public/railwatch/assets/tenant-path-G-6u9A-o.js +1 -0
- data/public/railwatch/assets/text-link-DfsiaCcP.js +1 -0
- data/public/railwatch/assets/textarea-Dye72uP7.js +1 -0
- data/public/railwatch/assets/timeline-CD7WHnbo.js +1 -0
- data/public/railwatch/assets/transition-B_AW8rMK.js +5 -0
- data/public/railwatch/assets/use-clipboard-ColgLyQ2.js +1 -0
- data/public/railwatch/icon.png +0 -0
- data/public/railwatch/icon.svg +5 -0
- data/public/railwatch/manifest.json +2171 -0
- data/public/railwatch/rails-vite.json +1 -0
- metadata +313 -4
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Railwatch
|
|
4
|
+
class LlmCallsController < DashboardController
|
|
5
|
+
NANOS_PER_DOLLAR = 1_000_000_000.0
|
|
6
|
+
|
|
7
|
+
def index
|
|
8
|
+
from, to = window_range
|
|
9
|
+
fields = FilterQuery.parse(params[:q]).fetch(:fields)
|
|
10
|
+
models = telemetry { model_rows(from, to) }
|
|
11
|
+
tools = telemetry { tool_rows(from, to) }
|
|
12
|
+
rows = telemetry { recent_rows(fields) }
|
|
13
|
+
render inertia: { models: models, tools: tools, recent: rows,
|
|
14
|
+
totals: totals(models, tools),
|
|
15
|
+
# Two record types over one table, the way Execution
|
|
16
|
+
# feeds request/job_attempt/... Waiting on a model is
|
|
17
|
+
# seconds of somebody else's compute; a tool call is
|
|
18
|
+
# your own code in milliseconds. One p95 over both
|
|
19
|
+
# describes neither.
|
|
20
|
+
series: series("llm_call"), tool_series: series("llm_tool"),
|
|
21
|
+
q: params[:q].to_s }
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
# One row per provider/model/operation group, from rollups: tokens and
|
|
27
|
+
# spend come out of Rollup#extra, which sums them on merge, so a 30-day
|
|
28
|
+
# window costs the same few rows as an hour.
|
|
29
|
+
def model_rows(from, to)
|
|
30
|
+
Telemetry::Rollup.for_type("llm_call").between(from, to).to_a
|
|
31
|
+
.group_by(&:group_hash).map { |group_hash, group_rows| model_row(group_hash, group_rows, from, to) }
|
|
32
|
+
.sort_by { |r| [ -(r[:cost] || 0), -r[:count] ] }.first(200)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Tools have no tokens and no price, so they rank by how much time they
|
|
36
|
+
# actually cost the request: slowest first.
|
|
37
|
+
def tool_rows(from, to)
|
|
38
|
+
Telemetry::Rollup.for_type("llm_tool").between(from, to).to_a
|
|
39
|
+
.group_by(&:group_hash).map do |group_hash, group_rows|
|
|
40
|
+
summary = Telemetry::Rollup.summarize(group_rows)
|
|
41
|
+
{ group_hash: group_hash, name: group_rows.max_by(&:bucket).name,
|
|
42
|
+
count: group_rows.sum(&:count), errors: group_rows.sum(&:error_count),
|
|
43
|
+
avg: (summary[:avg] / 1000.0).round(2), p95: (summary[:p95] / 1000.0).round(2),
|
|
44
|
+
max: (summary[:max] / 1000.0).round(2),
|
|
45
|
+
sparkline: Telemetry::Aggregations.sparkline(group_rows, from, to) }
|
|
46
|
+
end.sort_by { |r| -r[:p95] }.first(200)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def model_row(group_hash, group_rows, from, to)
|
|
50
|
+
count = group_rows.sum(&:count)
|
|
51
|
+
summary = Telemetry::Rollup.summarize(group_rows)
|
|
52
|
+
nanos = sum_extra(group_rows, "cost_nanos")
|
|
53
|
+
priced = sum_extra(group_rows, "priced")
|
|
54
|
+
unpriced = sum_extra(group_rows, "unpriced")
|
|
55
|
+
{
|
|
56
|
+
group_hash: group_hash, name: group_rows.max_by(&:bucket).name, count: count,
|
|
57
|
+
errors: group_rows.sum(&:error_count),
|
|
58
|
+
input_tokens: sum_extra(group_rows, "input_tokens"), output_tokens: sum_extra(group_rows, "output_tokens"),
|
|
59
|
+
cache_read_tokens: sum_extra(group_rows, "cache_read_tokens"),
|
|
60
|
+
cache_write_tokens: sum_extra(group_rows, "cache_write_tokens"),
|
|
61
|
+
# nil, not 0, when nothing in the group was priced: a table that shows
|
|
62
|
+
# $0.00 for an unpriced model is lying about the bill. `unpriced`
|
|
63
|
+
# carries how many calls the figure leaves out when it is partial.
|
|
64
|
+
cost: priced.zero? ? nil : nanos / NANOS_PER_DOLLAR, unpriced: unpriced,
|
|
65
|
+
# Neither of these is an error, so neither shows in the error rate.
|
|
66
|
+
truncated: sum_extra(group_rows, "truncated"),
|
|
67
|
+
with_attachments: sum_extra(group_rows, "with_attachments"),
|
|
68
|
+
avg: (summary[:avg] / 1000.0).round(2), p95: (summary[:p95] / 1000.0).round(2),
|
|
69
|
+
sparkline: Telemetry::Aggregations.sparkline(group_rows, from, to)
|
|
70
|
+
}
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def sum_extra(rows, key)
|
|
74
|
+
rows.sum { |r| r.extra[key].to_i }
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# `count` is model calls only. Tool calls are counted separately rather
|
|
78
|
+
# than folded in: they cost no tokens and no money, so adding them to the
|
|
79
|
+
# headline number inflates it with work that never reached a provider.
|
|
80
|
+
def totals(models, tools)
|
|
81
|
+
priced = models.reject { |m| m[:cost].nil? }
|
|
82
|
+
{
|
|
83
|
+
count: models.sum { |m| m[:count] }, errors: models.sum { |m| m[:errors] },
|
|
84
|
+
tool_count: tools.sum { |t| t[:count] }, tool_errors: tools.sum { |t| t[:errors] },
|
|
85
|
+
input_tokens: models.sum { |m| m[:input_tokens] }, output_tokens: models.sum { |m| m[:output_tokens] },
|
|
86
|
+
cost: priced.empty? ? nil : priced.sum { |m| m[:cost] }, unpriced: models.sum { |m| m[:unpriced] }
|
|
87
|
+
}
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def recent_rows(fields)
|
|
91
|
+
scope = Telemetry::LlmCall.between(*window_range)
|
|
92
|
+
scope = scope.where("model LIKE ?", "%#{Telemetry::LlmCall.sanitize_sql_like(fields['model'])}%") if fields["model"].present?
|
|
93
|
+
scope = scope.where(provider: fields["provider"]) if fields["provider"].present?
|
|
94
|
+
scope = scope.where(operation: fields["operation"]) if fields["operation"].present?
|
|
95
|
+
scope = scope.where(workflow_id: fields["workflow"]) if fields["workflow"].present?
|
|
96
|
+
scope = scope.where(finish_reason: fields["finish"]) if fields["finish"].present?
|
|
97
|
+
# "attachments:image" is the question a document-heavy app actually asks.
|
|
98
|
+
if fields["attachments"].present?
|
|
99
|
+
scope = if fields["attachments"] == "any"
|
|
100
|
+
scope.where.not(attachments: nil)
|
|
101
|
+
else
|
|
102
|
+
scope.where("attachment_types LIKE ?", "%#{Telemetry::LlmCall.sanitize_sql_like(fields['attachments'])}%")
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
scope.recent.limit(200).map { |r| row_props(r) }
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def row_props(call)
|
|
109
|
+
{ id: call.id, operation: call.operation, provider: call.provider, model: call.model,
|
|
110
|
+
response_model: call.response_model, tool_name: call.tool_name, duration: call.duration_ms.round(1),
|
|
111
|
+
status: call.status, error: call.error, streaming: call.streaming, message_count: call.message_count,
|
|
112
|
+
tool_count: call.tool_count, input_tokens: call.input_tokens, output_tokens: call.output_tokens,
|
|
113
|
+
cache_read_tokens: call.cache_read_tokens, cache_write_tokens: call.cache_write_tokens,
|
|
114
|
+
thinking_tokens: call.thinking_tokens, cost: call.cost, cost_reported: call.cost_reported,
|
|
115
|
+
workflow_id: call.workflow_id, workflow_name: call.workflow_name,
|
|
116
|
+
workflow_step_name: call.workflow_step_name,
|
|
117
|
+
finish_reason: call.finish_reason, provider_request_id: call.provider_request_id,
|
|
118
|
+
tools: call.tools, attachments: call.attachments, attachment_types: call.attachment_types,
|
|
119
|
+
attachment_names: call.attachment_names, tool_call_id: call.tool_call_id, params: call.params,
|
|
120
|
+
prompt: call.prompt, completion: call.completion, occurred_at: call.occurred_at,
|
|
121
|
+
execution_id: call.execution_id, execution_preview: call.execution_preview }
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Railwatch
|
|
4
|
+
class LogsController < DashboardController
|
|
5
|
+
def index
|
|
6
|
+
from, to = window_range
|
|
7
|
+
parsed = FilterQuery.parse(params[:q])
|
|
8
|
+
page_data = nil
|
|
9
|
+
page = lambda do
|
|
10
|
+
page_data ||= telemetry do
|
|
11
|
+
filtered = FilterQuery.apply(Telemetry::Log.all, resource: :logs, query: params[:q], from: from, to: to)
|
|
12
|
+
rows, meta = Telemetry::CursorPage.call(filtered, cursor: params[:cursor], limit: params[:limit],
|
|
13
|
+
context: telemetry_cursor_context(:logs))
|
|
14
|
+
[ rows.map { |l| log_row(l) }, meta, Telemetry::Log.fts_snippets(rows.map(&:id), parsed[:text]) ]
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
render inertia: {
|
|
18
|
+
logs: InertiaRails.merge { page.call[0] }, pagination: -> { page.call[1] },
|
|
19
|
+
counts: -> { level_counts(from, to) }, highlights: InertiaRails.deep_merge { page.call[2] }, q: params[:q].to_s
|
|
20
|
+
}
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
# The level facet counts the same rows the search does, except for the
|
|
26
|
+
# selected level itself, so switching level never disagrees with the table.
|
|
27
|
+
def level_counts(from, to)
|
|
28
|
+
telemetry do
|
|
29
|
+
FilterQuery.apply(Telemetry::Log.all, resource: :logs, query: params[:q], from: from, to: to,
|
|
30
|
+
except: %w[level status]).group(:level).count
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def log_row(log)
|
|
35
|
+
{ id: log.id, level: log.level, message: log.message.first(2000), tags: log.tags, occurred_at: log.occurred_at,
|
|
36
|
+
execution_id: log.execution_id, execution_source: log.execution_source, execution_preview: log.execution_preview,
|
|
37
|
+
source: log.source, tenant: log.app_tenant, user_ref: log.user_ref, context: log.context }
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Railwatch
|
|
4
|
+
class MailsController < DashboardController
|
|
5
|
+
def index
|
|
6
|
+
fields = FilterQuery.parse(params[:q]).fetch(:fields)
|
|
7
|
+
rows = telemetry do
|
|
8
|
+
scope = Telemetry::Mail.between(*window_range)
|
|
9
|
+
scope = scope.where(mailer: fields["mailer"]) if fields["mailer"].present?
|
|
10
|
+
# Mail rows have no "kind" column; delivery_method (smtp/test/deliver_later
|
|
11
|
+
# etc.) is the closest fit for a kind: filter token.
|
|
12
|
+
scope = scope.where(delivery_method: fields["kind"]) if fields["kind"].present?
|
|
13
|
+
scope.recent.limit(200).to_a
|
|
14
|
+
end
|
|
15
|
+
render inertia: { mailers: grouped("mail", limit: 100), series: series("mail"), q: params[:q].to_s,
|
|
16
|
+
recent: rows.map { |m| { id: m.id, mailer: m.mailer, subject: m.subject, to: m.to, cc: m.cc, bcc: m.bcc, attachments: m.attachments, delivery_method: m.delivery_method, duration: m.duration_ms.round(1), failed: m.failed, occurred_at: m.occurred_at, execution_id: m.execution_id, execution_preview: m.execution_preview } } }
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Railwatch
|
|
4
|
+
class NotificationsController < DashboardController
|
|
5
|
+
def index
|
|
6
|
+
rows = telemetry { Telemetry::Notification.between(*window_range).recent.limit(200).to_a }
|
|
7
|
+
render inertia: { groups: grouped("notification", limit: 100),
|
|
8
|
+
recent: rows.map { |n| { id: n.id, notifier: n.notifier, delivery_method: n.delivery_method, duration: n.duration_ms.round(1), failed: n.failed, occurred_at: n.occurred_at, execution_id: n.execution_id, execution_preview: n.execution_preview } } }
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Railwatch
|
|
4
|
+
class OutgoingRequestsController < DashboardController
|
|
5
|
+
def index
|
|
6
|
+
fields = FilterQuery.parse(params[:q]).fetch(:fields)
|
|
7
|
+
rows = telemetry do
|
|
8
|
+
scope = Telemetry::OutgoingRequest.between(*window_range)
|
|
9
|
+
scope = scope.where("host LIKE ?", "%#{Telemetry::OutgoingRequest.sanitize_sql_like(fields['host'])}%") if fields["host"].present?
|
|
10
|
+
if (range = FilterQuery.status_range(fields["status"]))
|
|
11
|
+
scope = scope.where(status_code: range)
|
|
12
|
+
end
|
|
13
|
+
scope.recent.limit(200).to_a
|
|
14
|
+
end
|
|
15
|
+
render inertia: { hosts: grouped("outgoing_request", limit: 100), series: series("outgoing_request"), q: params[:q].to_s,
|
|
16
|
+
recent: rows.map { |r| { id: r.id, host: r.host, method: r.method, url: r.url, status_code: r.status_code, duration: r.duration_ms.round(1), request_size: r.request_size, response_size: r.response_size, error: r.error, source: r.source, occurred_at: r.occurred_at, execution_id: r.execution_id, execution_preview: r.execution_preview, response_body: r.response_body } } }
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Railwatch
|
|
4
|
+
class OverviewController < DashboardController
|
|
5
|
+
def show
|
|
6
|
+
requests = grouped("request", limit: 8, order: :p95)
|
|
7
|
+
jobs = grouped("job_attempt", limit: 8)
|
|
8
|
+
render inertia: {
|
|
9
|
+
totals: { requests: summary_with_delta("request"), jobs: summary_with_delta("job_attempt") },
|
|
10
|
+
request_series: series("request"),
|
|
11
|
+
job_series: series("job_attempt"),
|
|
12
|
+
slow_routes: requests,
|
|
13
|
+
top_jobs: jobs,
|
|
14
|
+
issues: environment.issues.open.recent.limit(8).map { |i| issue_row(i) },
|
|
15
|
+
deploys: deploys_in_window,
|
|
16
|
+
release_health: release_health,
|
|
17
|
+
processes: telemetry { Telemetry::Process.recent.limit(20).group_by(&:server).transform_values { |ps| ps.first.slice(:role, :deploy, :ruby_version, :rails_version, :railwatch_version, :booted_at) } }
|
|
18
|
+
}
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
# Crash-free sessions for the release that is live now, over the page's
|
|
24
|
+
# window. nil (and the stat is not rendered) until the environment reports
|
|
25
|
+
# sessions at all.
|
|
26
|
+
def release_health
|
|
27
|
+
deploy = environment.deploys.recent.first or return nil
|
|
28
|
+
health = telemetry { Telemetry::ReleaseHealth.for_deploy(deploy.deploy, *window_range) }
|
|
29
|
+
return nil if health[:crash_free_sessions].nil?
|
|
30
|
+
health.slice(:sessions, :crash_free_sessions).merge(deploy: deploy.deploy, ref: deploy.short_ref)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def issue_row(i)
|
|
34
|
+
{ id: i.id, key: i.key, title: i.title, kind: i.kind, status: i.status, priority: i.priority, occurrences: i.occurrences,
|
|
35
|
+
affected_users: i.affected_users, last_seen_at: i.last_seen_at, culprit: i.culprit }
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Railwatch
|
|
4
|
+
class PeopleController < DashboardController
|
|
5
|
+
def index
|
|
6
|
+
from, to = window_range
|
|
7
|
+
active = telemetry do
|
|
8
|
+
Telemetry::Execution.requests.between(from, to).where.not(user_ref: nil).group(:user_ref)
|
|
9
|
+
.pluck(:user_ref, Arel.sql("COUNT(*)"), Arel.sql("SUM(CASE WHEN status >= 500 THEN 1 ELSE 0 END)"), Arel.sql("MAX(occurred_at)"))
|
|
10
|
+
.map { |ref, c, e, last| { ref: ref, requests: c, errors: e, last_seen_at: last } }.sort_by { |r| -r[:requests] }.first(200)
|
|
11
|
+
end
|
|
12
|
+
people = telemetry { Telemetry::Person.where(ref: active.map { |a| a[:ref] }).index_by(&:ref) }
|
|
13
|
+
render inertia: { people: active.map { |a| a.merge(name: people[a[:ref]]&.display_name, email: people[a[:ref]]&.email, tenant: people[a[:ref]]&.app_tenant) } }
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def show
|
|
17
|
+
ref = params[:id]
|
|
18
|
+
person = telemetry { Telemetry::Person.find_by(ref: ref) }
|
|
19
|
+
executions = telemetry { Telemetry::Execution.where(user_ref: ref).between(*window_range).recent.limit(100).map { |r| { execution_id: r.execution_id, kind: r.kind, name: r.name, status: r.status, outcome: r.outcome, duration: r.duration_ms.round(1), occurred_at: r.occurred_at, exception_preview: r.exception_preview } } }
|
|
20
|
+
exception_groups = telemetry { Telemetry::Exception.where(user_ref: ref).between(*window_range).group(:group_hash).count }
|
|
21
|
+
issues = environment.issues.where(group_hash: exception_groups.keys).map { |i| { id: i.id, key: i.key, title: i.title, status: i.status, count: exception_groups[i.group_hash] } }
|
|
22
|
+
render inertia: { person: person && { person_ref: person.ref, name: person.display_name, email: person.email, tenant: person.app_tenant, first_seen_at: person.first_seen_at, last_seen_at: person.last_seen_at }, person_ref: ref, executions: executions, issues: issues }
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Railwatch
|
|
4
|
+
class ProcessesController < DashboardController
|
|
5
|
+
def index
|
|
6
|
+
live_since = Telemetry::HealthSample::LIVE_WINDOW.ago
|
|
7
|
+
samples, series, queues, live_servers, processes = telemetry do
|
|
8
|
+
live = Telemetry::HealthSample.live(live_since).to_a
|
|
9
|
+
[ live, Telemetry::HealthSample.series(*window_range), Telemetry::HealthSample.queue_depths(live),
|
|
10
|
+
live.map(&:server) | Telemetry::Execution.servers_since(live_since),
|
|
11
|
+
Telemetry::Process.recent.limit(200).to_a ]
|
|
12
|
+
end
|
|
13
|
+
render inertia: { samples: samples.map { |s| sample_row(s) }, series: series, queues: queues,
|
|
14
|
+
silent_servers: environment.expected_servers - live_servers.compact,
|
|
15
|
+
processes: processes.map { |p| process_row(p) } }
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
def sample_row(s)
|
|
21
|
+
{ id: s.id, server: s.server, role: s.role, pid: s.pid, deploy: s.deploy, sampled_at: s.sampled_at,
|
|
22
|
+
threads_busy: s.threads_busy, threads_max: s.threads_max, backlog: s.backlog,
|
|
23
|
+
pool_busy: s.pool_busy, pool_size: s.pool_size, pool_waiting: s.pool_waiting,
|
|
24
|
+
queue_depth: s.queue_depth, queue_latency: s.queue_latency && (s.queue_latency / 1000.0).round(1), memory: s.memory }
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def process_row(p)
|
|
28
|
+
{ id: p.id, booted_at: p.booted_at, pid: p.pid, role: p.role, server: p.server, deploy: p.deploy,
|
|
29
|
+
ruby_version: p.ruby_version, rails_version: p.rails_version, railwatch_version: p.railwatch_version,
|
|
30
|
+
boot_seconds: p.boot_seconds, detail: p.detail }
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Railwatch
|
|
4
|
+
class ProfilesController < DashboardController
|
|
5
|
+
# Profiles are never rolled up (they are opt-in and sampled, so a rollup
|
|
6
|
+
# row would be noise), which means every number on these pages comes from
|
|
7
|
+
# the raw table. This caps how many rows the grouping pass reads; the
|
|
8
|
+
# StatStrip totals still come from SQL aggregates over the whole window.
|
|
9
|
+
SCAN_LIMIT = 5_000
|
|
10
|
+
|
|
11
|
+
def index
|
|
12
|
+
from, to = window_range
|
|
13
|
+
rows, count, avg_samples, profiled, executions = telemetry do
|
|
14
|
+
scope = Telemetry::Profile.between(from, to)
|
|
15
|
+
[ scope.recent.limit(SCAN_LIMIT).select(:id, :group_hash, :execution_preview, :duration, :samples, :occurred_at).to_a,
|
|
16
|
+
scope.count, scope.average(:samples), scope.distinct.count(:execution_id), Telemetry::Execution.between(from, to).count ]
|
|
17
|
+
end
|
|
18
|
+
render inertia: { profiles: groups(rows),
|
|
19
|
+
summary: { profiles: count, executions: executions, profiled: profiled, avg_samples: avg_samples.to_f.round(0) } }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def show
|
|
23
|
+
profile = telemetry { Telemetry::Profile.find(params[:id]) }
|
|
24
|
+
collapsed, truncated = profile.collapsed_capped
|
|
25
|
+
render inertia: { profile: profile_row(profile), collapsed: collapsed, truncated: truncated }
|
|
26
|
+
rescue Telemetry::BoundedGzip::Error
|
|
27
|
+
head :unprocessable_content
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
# Rows arrive newest-first, so the head of each group is the profile the
|
|
33
|
+
# page links to.
|
|
34
|
+
def groups(rows)
|
|
35
|
+
rows.group_by(&:group_hash).map { |group_hash, group|
|
|
36
|
+
newest = group.first
|
|
37
|
+
{ group_hash: group_hash, name: newest.execution_preview, profile_id: newest.id, count: group.size,
|
|
38
|
+
avg_duration: (group.sum { |row| row.duration.to_i } / group.size.to_f / 1000.0).round(2),
|
|
39
|
+
max_samples: group.filter_map(&:samples).max, last_seen_at: newest.occurred_at }
|
|
40
|
+
}.sort_by { |g| -g[:count] }.first(100)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def profile_row(row)
|
|
44
|
+
{ id: row.id, profiler: row.profiler, mode: row.mode, interval: row.interval, duration: row.duration_ms.round(2),
|
|
45
|
+
samples: row.samples, stacks_bytes: row.stacks_bytes, group_hash: row.group_hash, execution_id: row.execution_id,
|
|
46
|
+
execution_preview: row.execution_preview, execution_source: row.execution_source, occurred_at: row.occurred_at,
|
|
47
|
+
deploy: row.deploy, server: row.server }
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Railwatch
|
|
4
|
+
class QueriesController < DashboardController
|
|
5
|
+
def index
|
|
6
|
+
from, to = window_range
|
|
7
|
+
page_data = nil
|
|
8
|
+
page = lambda do
|
|
9
|
+
page_data ||= telemetry do
|
|
10
|
+
scope = FilterQuery.apply(Telemetry::Query.with_sql, resource: :queries, query: params[:q], from: from, to: to)
|
|
11
|
+
rows, meta = Telemetry::CursorPage.call(scope, cursor: params[:cursor], limit: params[:limit], order: :slowest,
|
|
12
|
+
context: telemetry_cursor_context(:queries))
|
|
13
|
+
[ rows.map { |q| query_row(q) }, meta ]
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
render inertia: {
|
|
17
|
+
queries: -> { grouped("query", limit: 200, order: params[:sort], dir: params[:dir]) },
|
|
18
|
+
n_plus_ones: -> { n_plus_ones(from, to) }, slowest: InertiaRails.merge { page.call.first },
|
|
19
|
+
pagination: -> { page.call.last }, q: params[:q].to_s, summary: -> { summary_with_delta("query") },
|
|
20
|
+
sort: params[:sort] || "count", dir: params[:dir] || "desc"
|
|
21
|
+
}
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def show
|
|
25
|
+
group_hash = params[:id]
|
|
26
|
+
rows = telemetry { Telemetry::Query.where(group_hash: group_hash).between(*window_range).recent.limit(100).with_sql.to_a }
|
|
27
|
+
summary = telemetry { Telemetry::Rollup.summarize(Telemetry::Rollup.for_type("query").where(group_hash: group_hash).between(*window_range)) }
|
|
28
|
+
sources = rows.filter_map(&:source).tally.sort_by { |_s, c| -c }.first(10)
|
|
29
|
+
callers = rows.map(&:execution_preview).compact.tally.sort_by { |_s, c| -c }.first(10)
|
|
30
|
+
render inertia: { group_hash: group_hash, sql: rows.first&.sql, summary: summary, series: series("query", group_hash: group_hash),
|
|
31
|
+
sources: sources, callers: callers, roles: rows.filter_map(&:role).tally.sort_by { |_r, c| -c },
|
|
32
|
+
explain: explain_sample(group_hash), samples: rows.first(50).map { |q| query_row(q) } }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def n_plus_ones(from, to)
|
|
38
|
+
telemetry do
|
|
39
|
+
Telemetry::NPlusOne.between(from, to).group(:group_hash).pluck(:group_hash, Arel.sql("COUNT(*)"), Arel.sql("MAX(sql)"), Arel.sql("MAX(source)"), Arel.sql("MAX(count)"))
|
|
40
|
+
.map { |g, c, sql, src, mx| { group_hash: g, occurrences: c, sql: sql, source: src, max_count: mx, suggestion: Telemetry::NPlusOne.new(sql: sql, source: src).suggestion } }
|
|
41
|
+
.sort_by { |r| -r[:occurrences] }.first(50)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# The newest sample in the group that the gem attached a query plan to.
|
|
46
|
+
# Only slow queries get one (RAILWATCH_CAPTURE_QUERY_EXPLAIN), so this is
|
|
47
|
+
# usually a different row than the newest sample overall.
|
|
48
|
+
def explain_sample(group_hash)
|
|
49
|
+
q = telemetry { Telemetry::Query.where(group_hash: group_hash).between(*window_range).where.not(explain: nil).recent.with_sql.first }
|
|
50
|
+
return nil if q.nil?
|
|
51
|
+
{ plan: q.explain, occurred_at: q.occurred_at, duration: q.duration_ms.round(3), execution_id: q.execution_id }
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def query_row(q)
|
|
55
|
+
{ id: q.id, sql: q.sql.first(300), duration: q.duration_ms.round(3), occurred_at: q.occurred_at, execution_id: q.execution_id,
|
|
56
|
+
execution_preview: q.execution_preview, source: q.source, connection: q.connection, row_count: q.row_count, group_hash: q.group_hash }
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Sentry-style release health: sessions per deploy, and the crash-free rates
|
|
4
|
+
# read off them. The release is the deploy string the gem stamps on every
|
|
5
|
+
# record it ships, so `show`'s :id is that string rather than a Deploy id.
|
|
6
|
+
module Railwatch
|
|
7
|
+
class ReleasesController < DashboardController
|
|
8
|
+
SESSIONS_SHOWN = 50
|
|
9
|
+
RELEASES_SHOWN = 50
|
|
10
|
+
# SQLite reads the deploy back out of an issue's sample payload, which is
|
|
11
|
+
# the release its latest occurrence came from.
|
|
12
|
+
SAMPLE_DEPLOY = "json_extract(sample, '$.deploy') = ?"
|
|
13
|
+
|
|
14
|
+
def index
|
|
15
|
+
from, to = window_range
|
|
16
|
+
deploys = environment.deploys.between(from, to).recent.limit(RELEASES_SHOWN).to_a
|
|
17
|
+
new_issues = new_issue_counts(deploys, to)
|
|
18
|
+
summary, series, adoption, health = telemetry do
|
|
19
|
+
[ Telemetry::ReleaseHealth.for_deploy(nil, from, to),
|
|
20
|
+
Telemetry::ReleaseHealth.series(nil, from, to),
|
|
21
|
+
Telemetry::ReleaseHealth.adoption(from, to),
|
|
22
|
+
deploys.to_h { |d| [ d.deploy, Telemetry::ReleaseHealth.for_deploy(d.deploy, from, to) ] } ]
|
|
23
|
+
end
|
|
24
|
+
releases = deploys.map do |d|
|
|
25
|
+
health.fetch(d.deploy).merge(deploy: d.deploy, ref: d.short_ref, name: d.name, deployed_at: d.deployed_at,
|
|
26
|
+
adoption: adoption[d.deploy] || 0.0, new_issues: new_issues[d.id].to_i)
|
|
27
|
+
end
|
|
28
|
+
render inertia: { releases: releases, series: series,
|
|
29
|
+
summary: summary.merge(releases: adoption.count { |_deploy, share| share.positive? }) }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def show
|
|
33
|
+
release = params[:id]
|
|
34
|
+
deploy = environment.deploys.find_by(deploy: release)
|
|
35
|
+
previous = deploy&.previous_deploy
|
|
36
|
+
from, to = deploy ? deploy.window : window_range
|
|
37
|
+
data = telemetry do
|
|
38
|
+
{ summary: Telemetry::ReleaseHealth.for_deploy(release, from, to),
|
|
39
|
+
previous_summary: previous && Telemetry::ReleaseHealth.for_deploy(previous.deploy, *previous.window),
|
|
40
|
+
series: Telemetry::ReleaseHealth.series(release, from, to),
|
|
41
|
+
sessions: Telemetry::Session.where(deploy: release).recent.limit(SESSIONS_SHOWN).map { |s| session_row(s) } }
|
|
42
|
+
end
|
|
43
|
+
render inertia: data.merge(
|
|
44
|
+
release: { deploy: release, ref: deploy&.short_ref || release.first(12), name: deploy&.name, deploy_id: deploy&.id,
|
|
45
|
+
deployed_at: deploy&.deployed_at, previous_ref: previous&.short_ref },
|
|
46
|
+
range: { from: from.iso8601, to: to.iso8601 },
|
|
47
|
+
new_issues: issue_rows(environment.issues.where(first_seen_at: from..to).or(environment.issues.where(SAMPLE_DEPLOY, release))),
|
|
48
|
+
resolved_issues: issue_rows(environment.issues.where(resolved_in_deploy: release)))
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def session_row(s)
|
|
54
|
+
{ id: s.id, session_id: s.session_id, source: s.source, status: s.status, user_ref: s.user_ref,
|
|
55
|
+
duration: s.duration_ms, requests: s.requests, visits: s.visits, errors: s.error_count, occurred_at: s.occurred_at,
|
|
56
|
+
started_at: s.started_at }
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def issue_rows(scope)
|
|
60
|
+
scope.recent.limit(50).map { |i| { id: i.id, key: i.key, title: i.title, status: i.status } }
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Issues first seen while each release was live -- one query for the table,
|
|
64
|
+
# not one per row. A release is live until the next one deploys.
|
|
65
|
+
def new_issue_counts(deploys, to)
|
|
66
|
+
return {} if deploys.empty?
|
|
67
|
+
|
|
68
|
+
ordered = deploys.sort_by(&:deployed_at)
|
|
69
|
+
windows = ordered.each_cons(2).to_h { |release, following| [ release.id, release.deployed_at...following.deployed_at ] }
|
|
70
|
+
windows[ordered.last.id] = ordered.last.deployed_at...to
|
|
71
|
+
first_seen = environment.issues.where(first_seen_at: ordered.first.deployed_at..to).pluck(:first_seen_at)
|
|
72
|
+
windows.transform_values { |window| first_seen.count { |at| window.cover?(at) } }
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Railwatch
|
|
4
|
+
class RequestsController < DashboardController
|
|
5
|
+
def index
|
|
6
|
+
routes = grouped("request", limit: 200, order: params[:sort], dir: params[:dir])
|
|
7
|
+
routes = apply_filters(routes, FilterQuery.parse(params[:q]).fetch(:fields))
|
|
8
|
+
render inertia: { routes: routes, series: series("request"), deploys: deploys_in_window,
|
|
9
|
+
sort: params[:sort] || "count", dir: params[:dir] || "desc", q: params[:q].to_s }
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# All requests to one route in the window.
|
|
13
|
+
def route
|
|
14
|
+
group_hash = params[:group_hash]
|
|
15
|
+
rows = telemetry { Telemetry::Execution.requests.where(group_hash: group_hash).between(*window_range).recent.limit(200).to_a }
|
|
16
|
+
render inertia: "requests/route", props: {
|
|
17
|
+
route: rows.first&.name || telemetry { Telemetry::Rollup.for_type("request").where(group_hash: group_hash).pick(:name) },
|
|
18
|
+
group_hash: group_hash, summary: summary_with_delta("request", group_hash: group_hash),
|
|
19
|
+
series: series("request", group_hash: group_hash), deploys: deploys_in_window,
|
|
20
|
+
requests: rows.map { |r| execution_row(r) }
|
|
21
|
+
}
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def show
|
|
25
|
+
exe = telemetry { Telemetry::Execution.find_by!(execution_id: params[:id]) }
|
|
26
|
+
render inertia: "executions/show", props: ExecutionPresenter.new(exe, environment).props
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
# Rollup rows for "request" carry no raw status codes, only aggregated
|
|
32
|
+
# count/errors/client_errors, so status:5xx etc. is a family match against
|
|
33
|
+
# those buckets rather than an exact code lookup.
|
|
34
|
+
def apply_filters(routes, fields)
|
|
35
|
+
routes = routes.select { |r| r[:name].start_with?("#{fields['method'].upcase} ") } if fields["method"].present?
|
|
36
|
+
routes = routes.select { |r| r[:name].include?(fields["route"]) } if fields["route"].present?
|
|
37
|
+
if (range = FilterQuery.status_range(fields["status"]))
|
|
38
|
+
routes = routes.select do |r|
|
|
39
|
+
case range.begin
|
|
40
|
+
when 500..599 then r[:errors].positive?
|
|
41
|
+
when 400..499 then r[:client_errors].positive?
|
|
42
|
+
else (r[:count] - r[:errors] - r[:client_errors]).positive?
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
routes
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def execution_row(r)
|
|
50
|
+
{ execution_id: r.execution_id, name: r.name, status: r.status, duration: r.duration_ms.round(2), occurred_at: r.occurred_at,
|
|
51
|
+
user_ref: r.user_ref, tenant: r.app_tenant, exception_preview: r.exception_preview, inertia_component: r.inertia_component,
|
|
52
|
+
queries: r.counters["queries"], deploy: r.deploy }
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Railwatch
|
|
4
|
+
class SavedViewsController < DashboardController
|
|
5
|
+
def create
|
|
6
|
+
view = environment.saved_views.new(create_params.merge(user: Viewer.user))
|
|
7
|
+
if view.save
|
|
8
|
+
redirect_back fallback_location: fallback, notice: "View saved"
|
|
9
|
+
else
|
|
10
|
+
redirect_back fallback_location: fallback, inertia: { errors: view.errors }
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def update
|
|
15
|
+
view = environment.saved_views.find(params[:id])
|
|
16
|
+
return head :forbidden unless editable?(view)
|
|
17
|
+
if view.update(update_params)
|
|
18
|
+
redirect_back fallback_location: fallback, notice: "View updated"
|
|
19
|
+
else
|
|
20
|
+
redirect_back fallback_location: fallback, inertia: { errors: view.errors }
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def destroy
|
|
25
|
+
view = environment.saved_views.find(params[:id])
|
|
26
|
+
return head :forbidden unless editable?(view)
|
|
27
|
+
view.destroy
|
|
28
|
+
redirect_back fallback_location: fallback, notice: "View deleted"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
# Its owner can change a view, and only its owner. The platform also lets
|
|
34
|
+
# an account admin, so a shared view does not outlive the person who made
|
|
35
|
+
# it; embedded mode has no account and no admin, and `|| true` here let
|
|
36
|
+
# every dashboard user edit and delete everyone else's views. An app whose
|
|
37
|
+
# dashboard_user resolver returns one identity for everybody is unaffected
|
|
38
|
+
# either way: one owner, one editor.
|
|
39
|
+
def editable?(view)
|
|
40
|
+
view.viewer_id == Viewer.user.id
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def fallback = application_environment_overview_path(application, environment)
|
|
44
|
+
|
|
45
|
+
def create_params
|
|
46
|
+
params.permit(:name, :page, :query, :window, :pinned, :shared, params: {})
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def update_params
|
|
50
|
+
params.permit(:name, :pinned, :shared)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Railwatch
|
|
4
|
+
class ScheduledTasksController < DashboardController
|
|
5
|
+
include TelemetryIdentity
|
|
6
|
+
|
|
7
|
+
def index
|
|
8
|
+
from, to = window_range
|
|
9
|
+
tasks = telemetry do
|
|
10
|
+
rows = Telemetry::Execution.scheduled.between(from, to).group(:task_key)
|
|
11
|
+
.pluck(:task_key, Arel.sql("COUNT(*)"), Arel.sql("SUM(CASE WHEN outcome = 'failed' THEN 1 ELSE 0 END)"), Arel.sql("AVG(duration)"), Arel.sql("MAX(occurred_at)"))
|
|
12
|
+
schedules = Telemetry::Execution.latest_schedules(rows.map(&:first))
|
|
13
|
+
rows.map { |k, c, f, d, last| { task_key: k, runs: c, failed: f, avg: (d.to_f / 1000.0).round(1), last_run_at: last, schedule: schedules[k] } }
|
|
14
|
+
end
|
|
15
|
+
tasks.each { |t| t[:next_run_at] = next_run(t[:schedule]) }
|
|
16
|
+
recent = telemetry do
|
|
17
|
+
rows = Telemetry::Execution.scheduled.between(from, to).recent.limit(100).to_a
|
|
18
|
+
people = origin_people(rows)
|
|
19
|
+
rows.map do |row|
|
|
20
|
+
{ execution_id: row.execution_id, task_key: row.task_key, name: row.name, outcome: row.outcome,
|
|
21
|
+
duration: row.duration_ms.round(1), occurred_at: row.occurred_at,
|
|
22
|
+
exception_preview: row.exception_preview }.merge(origin_identity(row, people))
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
render inertia: { tasks: tasks, runs: recent, missed: environment.issues.open.where("group_hash LIKE 'missed:%'").map { |i| { key: i.key, title: i.title, id: i.id } } }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def show
|
|
29
|
+
exe = telemetry { Telemetry::Execution.find_by!(execution_id: params[:id]) }
|
|
30
|
+
render inertia: "executions/show", props: ExecutionPresenter.new(exe, environment).props
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
# Solid Queue schedules are cron or natural language ("every 5 minutes"),
|
|
36
|
+
# both of which fugit parses; anything it can't parse has no next run.
|
|
37
|
+
def next_run(schedule)
|
|
38
|
+
return nil if schedule.blank?
|
|
39
|
+
require "fugit"
|
|
40
|
+
Fugit.parse(schedule)&.next_time&.to_t
|
|
41
|
+
rescue StandardError
|
|
42
|
+
nil
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|