railwatch 0.1.4 → 0.2.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/CHANGELOG.md +212 -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_migrate/20260918120000_widen_host_user_ids.rb +71 -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 +352 -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 +54 -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 +314 -4
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Railwatch
|
|
4
|
+
module Telemetry
|
|
5
|
+
class NPlusOne < TelemetryRecord
|
|
6
|
+
include Child
|
|
7
|
+
|
|
8
|
+
# A bound value in the SQL the gem stores. Railwatch::SqlNormalizer has
|
|
9
|
+
# already rewritten literals to "?" and one-element IN lists to "IN (?)"
|
|
10
|
+
# before an n+1 record is written; the bare-number branch only covers SQL
|
|
11
|
+
# that reached us unnormalized.
|
|
12
|
+
BIND = /(?:=\s*(?:\?|-?\d+)|IN\s*\(\s*(?:\?|-?\d+)\s*\))/i
|
|
13
|
+
|
|
14
|
+
# Matched against `shape`, which strips identifier quoting so one set of
|
|
15
|
+
# patterns covers SQLite/Postgres ("comments") and MySQL (`comments`).
|
|
16
|
+
# POLYMORPHIC has to be tried before CHILD_LOAD: an imageable_id filter
|
|
17
|
+
# matches both, and only the first one reads it as a polymorphic owner.
|
|
18
|
+
POLYMORPHIC = /\ASELECT\s+(?:\w+\.)?\*\s+FROM\s+(\w+)\s+WHERE\s+(?:\w+\.)?(\w+)_id\s*#{BIND}\s+AND\s+(?:\w+\.)?\2_type\s*#{BIND}/i
|
|
19
|
+
COUNT_LOAD = /\ASELECT\s+COUNT\(\*\)\s+(?:AS\s+\w+\s+)?FROM\s+(\w+)\s+WHERE\s+(?:\w+\.)?(\w+)_id\s*#{BIND}/i
|
|
20
|
+
EXISTS_LOAD = /\ASELECT\s+(?:1\s+AS\s+one|EXISTS\s*\(\s*SELECT\s+1)\b.*?\sFROM\s+(\w+)\s+WHERE\s+(?:\w+\.)?(\w+)_id\s*#{BIND}/i
|
|
21
|
+
CHILD_LOAD = /\ASELECT\s+(?:\w+\.)?\*\s+FROM\s+(\w+)\s+WHERE\s+(?:\w+\.)?(\w+)_id\s*#{BIND}/i
|
|
22
|
+
PARENT_LOAD = /\ASELECT\s+(?:\w+\.)?\*\s+FROM\s+(\w+)\s+WHERE\s+(?:\w+\.)?id\s*#{BIND}/i
|
|
23
|
+
|
|
24
|
+
def timeline_label
|
|
25
|
+
sql.to_s.first(120)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# A concrete fix for the repeated statement, read off its SQL shape:
|
|
29
|
+
# {kind:, parent:, association:, code:, explanation:}, or nil when the
|
|
30
|
+
# statement isn't a shape a preload or counter cache can fix.
|
|
31
|
+
def suggestion
|
|
32
|
+
s = shape
|
|
33
|
+
polymorphic_load(s) || count_load(s) || exists_load(s) || child_load(s) || parent_load(s)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def shape
|
|
39
|
+
sql.to_s.delete('"`').squeeze(" ").strip
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def polymorphic_load(s)
|
|
43
|
+
m = POLYMORPHIC.match(s) or return nil
|
|
44
|
+
table, as = m[1], m[2]
|
|
45
|
+
{ kind: "polymorphic", parent: nil, association: table,
|
|
46
|
+
code: ".includes(:#{table})",
|
|
47
|
+
explanation: "Each owner loads its #{table} in a separate query. #{table} is polymorphic (belongs_to :#{as}), " \
|
|
48
|
+
"so add .includes(:#{table}) on the collection you iterate#{at_source}." }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def count_load(s)
|
|
52
|
+
m = COUNT_LOAD.match(s) or return nil
|
|
53
|
+
table, parent = m[1], m[2].classify
|
|
54
|
+
{ kind: "counter_cache", parent: parent, association: table,
|
|
55
|
+
code: "# app/models/#{m[1].singularize}.rb\nbelongs_to :#{m[2]}, counter_cache: true\n\n# then read\n#{m[2]}.#{table}_count",
|
|
56
|
+
explanation: "Each #{parent} counts its #{table} with its own COUNT query#{at_source}. " \
|
|
57
|
+
"A counter cache keeps the total on #{parent.tableize}.#{table}_count, so #{m[2]}.#{table}_count needs no query." }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def exists_load(s)
|
|
61
|
+
m = EXISTS_LOAD.match(s) or return nil
|
|
62
|
+
table, parent = m[1], m[2].classify
|
|
63
|
+
{ kind: "exists", parent: parent, association: table,
|
|
64
|
+
code: "#{parent}.includes(:#{table})\n\n# then\n#{m[2]}.#{table}.any?",
|
|
65
|
+
explanation: "Each #{parent} checks whether it has #{table} with its own query#{at_source}. " \
|
|
66
|
+
"Preload with #{parent}.includes(:#{table}) and call .any? on the loaded collection, or add a counter cache." }
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def child_load(s)
|
|
70
|
+
m = CHILD_LOAD.match(s) or return nil
|
|
71
|
+
table, parent = m[1], m[2].classify
|
|
72
|
+
{ kind: "has_many", parent: parent, association: table,
|
|
73
|
+
code: "#{parent}.includes(:#{table})",
|
|
74
|
+
explanation: "Each #{parent} loads its #{table} in a separate query#{at_source}. " \
|
|
75
|
+
"Preload them with #{parent}.includes(:#{table}) where the #{parent.tableize} are loaded." }
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def parent_load(s)
|
|
79
|
+
m = PARENT_LOAD.match(s) or return nil
|
|
80
|
+
association = m[1].singularize
|
|
81
|
+
{ kind: "belongs_to", parent: nil, association: association,
|
|
82
|
+
code: ".includes(:#{association})",
|
|
83
|
+
explanation: "Each row loads its #{association} one at a time. " \
|
|
84
|
+
"Add .includes(:#{association}) on the collection you iterate#{at_source}." }
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def at_source
|
|
88
|
+
source.present? ? " at #{source}" : ""
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Railwatch
|
|
4
|
+
module Telemetry
|
|
5
|
+
# A monitored application's user, as seen in telemetry. Keyed by ref
|
|
6
|
+
# ("tenant:id" or "id") so the same person across tenants stays distinct.
|
|
7
|
+
class Person < TelemetryRecord
|
|
8
|
+
scope :recent, -> { order(last_seen_at: :desc) }
|
|
9
|
+
|
|
10
|
+
def self.touch_from_record(rec, at:)
|
|
11
|
+
touch_all([ [ rec, at ] ])
|
|
12
|
+
find_by(ref: rec["id"].to_s)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# One upsert per batch instead of a find-and-save per user record: the
|
|
16
|
+
# gem emits a user record once per user per hour per process, so a busy
|
|
17
|
+
# batch carries dozens. ON CONFLICT keeps first_seen_at and only
|
|
18
|
+
# overwrites name/email/tenant when the new value is present.
|
|
19
|
+
def self.touch_all(pairs)
|
|
20
|
+
rows = pairs.filter_map do |rec, at|
|
|
21
|
+
ref = rec["id"].to_s
|
|
22
|
+
next if ref.empty?
|
|
23
|
+
{ ref: ref, name: rec["name"].presence, email: rec["email"].presence, app_tenant: rec["tenant"].presence,
|
|
24
|
+
first_seen_at: at, last_seen_at: at, requests_count: 0, exceptions_count: 0 }
|
|
25
|
+
end
|
|
26
|
+
return 0 if rows.empty?
|
|
27
|
+
|
|
28
|
+
# Latest timestamp wins when the same ref repeats within a batch.
|
|
29
|
+
rows = rows.group_by { |r| r[:ref] }.map { |_ref, dup| dup.max_by { |r| r[:last_seen_at] } }
|
|
30
|
+
upsert_all(rows, unique_by: :ref, record_timestamps: false,
|
|
31
|
+
on_duplicate: Arel.sql(<<~SQL.squish))
|
|
32
|
+
name = COALESCE(excluded.name, people.name),
|
|
33
|
+
email = COALESCE(excluded.email, people.email),
|
|
34
|
+
app_tenant = COALESCE(excluded.app_tenant, people.app_tenant),
|
|
35
|
+
first_seen_at = COALESCE(people.first_seen_at, excluded.first_seen_at),
|
|
36
|
+
last_seen_at = MAX(people.last_seen_at, excluded.last_seen_at)
|
|
37
|
+
SQL
|
|
38
|
+
rows.size
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def executions
|
|
42
|
+
Execution.where(user_ref: ref)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def display_name
|
|
46
|
+
name.presence || email.presence || ref
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
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
|
|
@@ -0,0 +1,50 @@
|
|
|
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 timeline_label
|
|
46
|
+
sql.to_s.first(120)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
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
|
|
@@ -0,0 +1,63 @@
|
|
|
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
|
+
# Hourly points for the stacked sessions-by-status chart.
|
|
40
|
+
def self.series(deploy, from, to)
|
|
41
|
+
between(from, to).for_release(deploy).group(:bucket).order(:bucket)
|
|
42
|
+
.pluck(:bucket, Arel.sql("SUM(sessions)"), Arel.sql("SUM(sessions_errored)"), Arel.sql("SUM(sessions_crashed)"))
|
|
43
|
+
.map { |bucket, sessions, errored, crashed|
|
|
44
|
+
{ t: bucket, sessions: sessions, ok: sessions - errored - crashed, errored: errored, crashed: crashed }
|
|
45
|
+
}
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Each release's share of the window's sessions, as a percentage -- how
|
|
49
|
+
# far a new release has rolled out.
|
|
50
|
+
def self.adoption(from, to)
|
|
51
|
+
counts = between(from, to).group(:deploy).sum(:sessions)
|
|
52
|
+
total = counts.values.sum
|
|
53
|
+
return counts.transform_values { 0.0 } if total.zero?
|
|
54
|
+
counts.transform_values { |n| (n * 100.0 / total).round(1) }
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def self.rate(numerator, denominator)
|
|
58
|
+
return nil if denominator.to_i.zero?
|
|
59
|
+
(numerator * 100.0 / denominator).round(2)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,78 @@
|
|
|
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 } if rows.empty?
|
|
58
|
+
# merge! pushes the row's centroids into one accumulating digest.
|
|
59
|
+
# `+` built a brand-new digest from both operands' centroids on every
|
|
60
|
+
# row, so merging N rows re-pushed every earlier centroid N times:
|
|
61
|
+
# 311 rows took 1.4s where merge! takes 0.5s, for the same percentiles.
|
|
62
|
+
merged = TDigest::TDigest.new(0.01)
|
|
63
|
+
rows.each { |r| merged.merge!(TDigest::TDigest.from_bytes(r.digest)) if r.digest }
|
|
64
|
+
count = rows.sum(&:count)
|
|
65
|
+
{
|
|
66
|
+
count: count,
|
|
67
|
+
errors: rows.sum(&:error_count),
|
|
68
|
+
client_errors: rows.sum(&:client_error_count),
|
|
69
|
+
avg: count.zero? ? 0 : (rows.sum(&:duration_sum) / count),
|
|
70
|
+
p50: merged.percentile(0.5).to_i,
|
|
71
|
+
p95: merged.percentile(0.95).to_i,
|
|
72
|
+
p99: merged.percentile(0.99).to_i,
|
|
73
|
+
max: rows.map(&:duration_max).max
|
|
74
|
+
}
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
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
|
|
@@ -0,0 +1,19 @@
|
|
|
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
|