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,101 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Railwatch
|
|
4
|
+
require "stringio"
|
|
5
|
+
require "zlib"
|
|
6
|
+
|
|
7
|
+
module Telemetry
|
|
8
|
+
# Reads untrusted gzip streams without ever asking zlib for more than the
|
|
9
|
+
# configured output ceiling. Ingest uses verify! without retaining the
|
|
10
|
+
# expanded bytes; read paths either return a bounded body or a bounded
|
|
11
|
+
# prefix for displays that can be truncated.
|
|
12
|
+
module BoundedGzip
|
|
13
|
+
READ_SIZE = 64.kilobytes
|
|
14
|
+
|
|
15
|
+
class Error < ArgumentError; end
|
|
16
|
+
class Malformed < Error; end
|
|
17
|
+
class TooLarge < Error; end
|
|
18
|
+
class SizeMismatch < Error; end
|
|
19
|
+
|
|
20
|
+
module_function
|
|
21
|
+
|
|
22
|
+
def decompress(compressed, max_bytes:)
|
|
23
|
+
output = String.new(encoding: Encoding::BINARY)
|
|
24
|
+
each_chunk(compressed, max_bytes: max_bytes) { |chunk| output << chunk }
|
|
25
|
+
output
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Returns [prefix, truncated?]. Once one byte beyond the display limit is
|
|
29
|
+
# observed, decompression stops instead of expanding the rest of a legacy
|
|
30
|
+
# gzip bomb merely to prove that it was truncated.
|
|
31
|
+
def decompress_capped(compressed, limit:)
|
|
32
|
+
output = String.new(encoding: Encoding::BINARY)
|
|
33
|
+
each_chunk(compressed, max_bytes: limit) { |chunk| output << chunk }
|
|
34
|
+
[ output, false ]
|
|
35
|
+
rescue TooLarge
|
|
36
|
+
[ output, true ]
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def verify!(compressed, max_bytes:, expected_bytes:, utf8: false)
|
|
40
|
+
unless expected_bytes.is_a?(Integer) && expected_bytes.between?(0, max_bytes)
|
|
41
|
+
raise SizeMismatch, "declared size #{expected_bytes} exceeds the #{max_bytes} byte ceiling"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
validator = Encoding::Converter.new(Encoding::UTF_8, Encoding::UTF_16LE) if utf8
|
|
45
|
+
actual_bytes = each_chunk(compressed, max_bytes: max_bytes) do |chunk|
|
|
46
|
+
validator&.convert(chunk)
|
|
47
|
+
end
|
|
48
|
+
validator&.finish
|
|
49
|
+
return actual_bytes if actual_bytes == expected_bytes
|
|
50
|
+
|
|
51
|
+
raise SizeMismatch, "declared size #{expected_bytes} does not match decompressed size #{actual_bytes}"
|
|
52
|
+
rescue Encoding::InvalidByteSequenceError, Encoding::UndefinedConversionError
|
|
53
|
+
raise Malformed, "decompressed content is not valid UTF-8"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def utf8!(text)
|
|
57
|
+
text.force_encoding(Encoding::UTF_8)
|
|
58
|
+
return text if text.valid_encoding?
|
|
59
|
+
|
|
60
|
+
raise Malformed, "decompressed content is not valid UTF-8"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def each_chunk(compressed, max_bytes:)
|
|
64
|
+
raise ArgumentError, "max_bytes must be non-negative" unless max_bytes.is_a?(Integer) && max_bytes >= 0
|
|
65
|
+
|
|
66
|
+
# StringIO and zlib operate on bytes regardless of the String's
|
|
67
|
+
# encoding. Avoid String#b here: it duplicates even an already-binary
|
|
68
|
+
# database BLOB, adding another full compressed-body allocation.
|
|
69
|
+
source = StringIO.new(compressed.to_s)
|
|
70
|
+
reader = Zlib::GzipReader.new(source)
|
|
71
|
+
total = 0
|
|
72
|
+
loop do
|
|
73
|
+
# The extra byte proves that a stream at the boundary has ended. Only
|
|
74
|
+
# the portion up to the ceiling is ever yielded or retained.
|
|
75
|
+
chunk = reader.readpartial([ READ_SIZE, max_bytes - total + 1 ].min)
|
|
76
|
+
if total + chunk.bytesize > max_bytes
|
|
77
|
+
remaining = max_bytes - total
|
|
78
|
+
yield chunk.byteslice(0, remaining) if remaining.positive?
|
|
79
|
+
raise TooLarge, "decompressed content exceeds #{max_bytes} bytes"
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
total += chunk.bytesize
|
|
83
|
+
yield chunk
|
|
84
|
+
end
|
|
85
|
+
rescue EOFError
|
|
86
|
+
# GzipReader reads ahead in fixed-size blocks. `unused` catches bytes
|
|
87
|
+
# already buffered past the gzip member; checking the underlying IO is
|
|
88
|
+
# also required when the member ends exactly on a read boundary and
|
|
89
|
+
# the trailing bytes have not been pulled into zlib's buffer yet.
|
|
90
|
+
raise Malformed, "gzip stream has trailing data" if reader.unused.present? || !source.eof?
|
|
91
|
+
|
|
92
|
+
total
|
|
93
|
+
rescue Zlib::Error => e
|
|
94
|
+
raise Malformed, "invalid gzip stream: #{e.message}"
|
|
95
|
+
ensure
|
|
96
|
+
reader&.close
|
|
97
|
+
end
|
|
98
|
+
private_class_method :each_chunk
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Railwatch
|
|
4
|
+
module Telemetry
|
|
5
|
+
class CacheEvent < TelemetryRecord
|
|
6
|
+
include Child
|
|
7
|
+
|
|
8
|
+
# `type` is the cache operation (hit, miss, write...), not STI.
|
|
9
|
+
self.inheritance_column = nil
|
|
10
|
+
|
|
11
|
+
def timeline_label
|
|
12
|
+
"#{type} #{key}".first(120)
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Railwatch
|
|
4
|
+
module Telemetry
|
|
5
|
+
# Shared behaviour for every record that belongs to an execution.
|
|
6
|
+
module Child
|
|
7
|
+
extend ActiveSupport::Concern
|
|
8
|
+
|
|
9
|
+
included do
|
|
10
|
+
scope :recent, -> { order(occurred_at: :desc) }
|
|
11
|
+
scope :between, ->(from, to) { where(occurred_at: from..to) }
|
|
12
|
+
scope :in_execution, ->(id) { where(execution_id: id) }
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
class_methods do
|
|
16
|
+
# One execution's rows for the waterfall; a child that resolves a
|
|
17
|
+
# column from elsewhere (Query) overrides this.
|
|
18
|
+
def timeline_scope(execution_id)
|
|
19
|
+
in_execution(execution_id)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def execution
|
|
24
|
+
Execution.find_by(execution_id: execution_id)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def duration_ms
|
|
28
|
+
respond_to?(:duration) && duration ? duration / 1000.0 : nil
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# parent_start is passed in by Execution#timeline, which already holds
|
|
32
|
+
# the parent. Resolving it here instead cost one query per child row,
|
|
33
|
+
# so a waterfall of a few hundred logs and queries was a few hundred
|
|
34
|
+
# lookups of the same execution.
|
|
35
|
+
def timeline_entry(type, parent_start = nil)
|
|
36
|
+
parent_start ||= execution&.occurred_at
|
|
37
|
+
{
|
|
38
|
+
type: type.to_s.singularize,
|
|
39
|
+
id: id,
|
|
40
|
+
offset: parent_start ? ((occurred_at - parent_start) * 1000.0).round(3) : 0,
|
|
41
|
+
duration: duration_ms,
|
|
42
|
+
label: timeline_label,
|
|
43
|
+
stage: execution_stage,
|
|
44
|
+
source: (self[:source] if has_attribute?(:source))
|
|
45
|
+
}
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def timeline_label
|
|
49
|
+
try(:name) || try(:key) || try(:message)&.first(120) || self.class.name.demodulize
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Railwatch
|
|
4
|
+
module Telemetry
|
|
5
|
+
# Stable keyset pagination for high-volume telemetry tables. Cursors are
|
|
6
|
+
# signed and bind their sort shape, so clients cannot inject columns or use
|
|
7
|
+
# a cursor from one resource/order against another.
|
|
8
|
+
class CursorPage
|
|
9
|
+
class InvalidCursor < ArgumentError; end
|
|
10
|
+
|
|
11
|
+
DEFAULT_LIMIT = 50
|
|
12
|
+
MAX_LIMIT = 200
|
|
13
|
+
ORDERS = {
|
|
14
|
+
recent: { column: :occurred_at, direction: :desc, type: :time },
|
|
15
|
+
slowest: { column: :duration, direction: :desc, type: :integer }
|
|
16
|
+
}.freeze
|
|
17
|
+
|
|
18
|
+
def self.call(scope, cursor: nil, limit: nil, order: :recent, context: nil)
|
|
19
|
+
new(scope, cursor: cursor, limit: limit, order: order, context: context).call
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def initialize(scope, cursor:, limit:, order:, context:)
|
|
23
|
+
@scope = scope
|
|
24
|
+
@cursor = cursor
|
|
25
|
+
parsed_limit = limit.to_i
|
|
26
|
+
parsed_limit = DEFAULT_LIMIT unless parsed_limit.positive?
|
|
27
|
+
@limit = [ parsed_limit, MAX_LIMIT ].min
|
|
28
|
+
@order_name = order.to_sym
|
|
29
|
+
@order = ORDERS.fetch(@order_name)
|
|
30
|
+
@context = context.to_s
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def call
|
|
34
|
+
relation = apply_cursor(scope)
|
|
35
|
+
relation = relation.from("#{relation.klass.quoted_table_name} INDEXED BY idx_queries_slowest") if force_slowest_index?
|
|
36
|
+
rows = relation.reorder(order[:column] => order[:direction], id: order[:direction]).limit(limit + 1).to_a
|
|
37
|
+
has_more = rows.length > limit
|
|
38
|
+
rows = rows.first(limit)
|
|
39
|
+
next_cursor = encode(rows.last) if has_more
|
|
40
|
+
[ rows, { limit: limit, next_cursor: next_cursor, has_more: has_more } ]
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
attr_reader :scope, :cursor, :limit, :order, :order_name, :context
|
|
46
|
+
|
|
47
|
+
# SQLite's planner costs the duration-led covering index higher than
|
|
48
|
+
# scanning the whole occurred_at window and sorting it, even with fresh
|
|
49
|
+
# statistics, so it never picks it on its own. Forced only for the one
|
|
50
|
+
# (model, order) pair the index exists for.
|
|
51
|
+
# INDEXED BY is SQLite's own syntax, and telemetry is a SQLite database
|
|
52
|
+
# in both the platform and an embedded install. Ask anyway, so pointing
|
|
53
|
+
# this at anything else degrades to the planner's own choice rather
|
|
54
|
+
# than a syntax error on every page load.
|
|
55
|
+
def force_slowest_index?
|
|
56
|
+
order_name == :slowest && scope.klass == Telemetry::Query &&
|
|
57
|
+
scope.klass.connection.adapter_name.match?(/sqlite/i)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def apply_cursor(relation)
|
|
61
|
+
values = decode
|
|
62
|
+
return relation unless values
|
|
63
|
+
|
|
64
|
+
column = relation.klass.arel_table[order[:column]]
|
|
65
|
+
id = relation.klass.arel_table[:id]
|
|
66
|
+
comparator = order[:direction] == :desc ? :lt : :gt
|
|
67
|
+
relation.where(column.public_send(comparator, values[:value])
|
|
68
|
+
.or(column.eq(values[:value]).and(id.public_send(comparator, values[:id]))))
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def encode(row)
|
|
72
|
+
return unless row
|
|
73
|
+
value = row.public_send(order[:column])
|
|
74
|
+
value = value.iso8601(6) if order[:type] == :time
|
|
75
|
+
# The page size is deliberately not bound: changing it mid-scroll must
|
|
76
|
+
# not hard-fail. The cursor is a stable position, not a security
|
|
77
|
+
# boundary -- the scope it is applied to is already tenant-scoped here.
|
|
78
|
+
verifier.generate({ v: 1, model: scope.klass.name, order: order_name,
|
|
79
|
+
context: context, value: value, id: row.id })
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def decode
|
|
83
|
+
return if cursor.blank?
|
|
84
|
+
payload = verifier.verify(cursor.to_s).symbolize_keys
|
|
85
|
+
valid_shape = payload[:v] == 1 && payload[:model] == scope.klass.name &&
|
|
86
|
+
payload[:order].to_s == order_name.to_s && payload[:context] == context
|
|
87
|
+
raise InvalidCursor, "invalid telemetry cursor" unless valid_shape
|
|
88
|
+
value = order[:type] == :time ? Time.iso8601(payload[:value].to_s) : Integer(payload[:value].to_s, 10)
|
|
89
|
+
{ value: value, id: Integer(payload[:id].to_s, 10) }
|
|
90
|
+
rescue ActiveSupport::MessageVerifier::InvalidSignature, ArgumentError, TypeError
|
|
91
|
+
raise InvalidCursor, "invalid telemetry cursor"
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def verifier
|
|
95
|
+
Rails.application.message_verifier("telemetry_cursor")
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Railwatch
|
|
4
|
+
module Telemetry
|
|
5
|
+
class Exception < TelemetryRecord
|
|
6
|
+
include Child
|
|
7
|
+
|
|
8
|
+
self.table_name = "exceptions"
|
|
9
|
+
|
|
10
|
+
scope :unhandled, -> { where(handled: false) }
|
|
11
|
+
|
|
12
|
+
def timeline_label
|
|
13
|
+
"#{class_name}: #{message.to_s.first(100)}"
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def app_frames
|
|
17
|
+
frames.select { |f| f["in_app"] }
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Railwatch
|
|
4
|
+
module Telemetry
|
|
5
|
+
# A request, job attempt, scheduled task, command, or channel action. The parent of
|
|
6
|
+
# every other telemetry row via execution_id.
|
|
7
|
+
class Execution < TelemetryRecord
|
|
8
|
+
KINDS = %w[request job_attempt scheduled_task command channel_action].freeze
|
|
9
|
+
|
|
10
|
+
scope :of_kind, ->(kind) { where(kind: kind) }
|
|
11
|
+
scope :requests, -> { where(kind: "request") }
|
|
12
|
+
scope :jobs, -> { where(kind: "job_attempt") }
|
|
13
|
+
scope :scheduled, -> { where(kind: "scheduled_task") }
|
|
14
|
+
scope :commands, -> { where(kind: "command") }
|
|
15
|
+
scope :channels, -> { where(kind: "channel_action") }
|
|
16
|
+
scope :recent, -> { order(occurred_at: :desc) }
|
|
17
|
+
scope :between, ->(from, to) { where(occurred_at: from..to) }
|
|
18
|
+
scope :failed, -> { where("status >= 500 OR outcome = 'failed'") }
|
|
19
|
+
|
|
20
|
+
# Servers that reported an execution since `time`. Asked once per kind:
|
|
21
|
+
# there is no index led by occurred_at alone, so the obvious
|
|
22
|
+
# `where(occurred_at: time..).distinct.pluck(:server)` scans the whole
|
|
23
|
+
# table -- 15 to 26 seconds every five minutes on the platform's own
|
|
24
|
+
# 500k-row tenant, from SilentHostCheckJob -- while (kind, occurred_at)
|
|
25
|
+
# seeks straight to the window for each of the five kinds.
|
|
26
|
+
def self.servers_since(time)
|
|
27
|
+
KINDS.flat_map { |kind| where(kind: kind, occurred_at: time..).distinct.pluck(:server) }.uniq
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# The schedule string each task key last ran with, in one query: the
|
|
31
|
+
# newest scheduled_task row per key carries it in detail. The scheduled
|
|
32
|
+
# tasks page and CheckScheduledTasksJob both used to look it up per key.
|
|
33
|
+
def self.latest_schedules(keys)
|
|
34
|
+
return {} if keys.empty?
|
|
35
|
+
latest = scheduled.where(task_key: keys).group(:task_key).select(:task_key, Arel.sql("MAX(id) AS id"))
|
|
36
|
+
scheduled.where(id: latest.map(&:id))
|
|
37
|
+
.pluck(:task_key, Arel.sql("json_extract(detail, '$.schedule')")).to_h
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
CHILDREN = {
|
|
41
|
+
queries: Query, exceptions: Exception, cache_events: CacheEvent, mails: Mail,
|
|
42
|
+
broadcasts: Broadcast, outgoing_requests: OutgoingRequest, storage_ops: StorageOp,
|
|
43
|
+
view_renders: ViewRender, logs: Log, enqueued_jobs: EnqueuedJob, transactions: Transaction,
|
|
44
|
+
notifications: Notification, spans: Span, profiles: Profile, attachments: Attachment,
|
|
45
|
+
llm_calls: LlmCall
|
|
46
|
+
}.freeze
|
|
47
|
+
|
|
48
|
+
CHILDREN.each do |name, klass|
|
|
49
|
+
define_method(name) { klass.where(execution_id: execution_id) }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# All children merged and ordered for the waterfall timeline.
|
|
53
|
+
def timeline
|
|
54
|
+
CHILDREN.flat_map { |name, klass| klass.timeline_scope(execution_id).map { |r| r.timeline_entry(name, occurred_at) } }
|
|
55
|
+
.sort_by { |e| e[:offset] }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def failed?
|
|
59
|
+
(status && status >= 500) || outcome == "failed"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def duration_ms
|
|
63
|
+
duration / 1000.0
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def children_count
|
|
67
|
+
counters.values.sum
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Railwatch
|
|
4
|
+
module Telemetry
|
|
5
|
+
# Periodic Puma / Solid Queue health sample from one process: thread pool
|
|
6
|
+
# utilisation, socket backlog, connection pool pressure, queue depth and
|
|
7
|
+
# the age of the oldest ready job.
|
|
8
|
+
class HealthSample < TelemetryRecord
|
|
9
|
+
# The gem reports every 15s, so a process that has not sampled in ten
|
|
10
|
+
# minutes is gone rather than idle.
|
|
11
|
+
LIVE_WINDOW = 10.minutes
|
|
12
|
+
BUCKET = 5.minutes
|
|
13
|
+
|
|
14
|
+
scope :recent, -> { order(sampled_at: :desc) }
|
|
15
|
+
scope :between, ->(from, to) { where(sampled_at: from..to) }
|
|
16
|
+
|
|
17
|
+
# The newest sample from each (server, pid) pair seen since `time`: one
|
|
18
|
+
# row per process still reporting.
|
|
19
|
+
def self.live(time = LIVE_WINDOW.ago)
|
|
20
|
+
where(id: where(sampled_at: time..).group(:server, :pid).select(Arel.sql("MAX(id)"))).order(:server, :pid)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Health over the window, one point per BUCKET: thread utilisation is a
|
|
24
|
+
# percentage averaged across the processes that sampled into the bucket,
|
|
25
|
+
# backlog is summed across them, and the queue figures are the worst
|
|
26
|
+
# seen (any process's reading covers the whole queue).
|
|
27
|
+
def self.series(from, to, bucket: BUCKET)
|
|
28
|
+
slice = bucket_expression(bucket)
|
|
29
|
+
between(from, to).group(slice).order(slice)
|
|
30
|
+
.pluck(slice,
|
|
31
|
+
Arel.sql("AVG(CASE WHEN threads_max > 0 THEN threads_busy * 100.0 / threads_max END)"),
|
|
32
|
+
Arel.sql("SUM(backlog)"), Arel.sql("MAX(queue_depth)"), Arel.sql("MAX(queue_latency)"))
|
|
33
|
+
.map do |t, utilisation, backlog, depth, latency|
|
|
34
|
+
{ t: t, utilisation: utilisation.to_f.round(1), backlog: backlog.to_i,
|
|
35
|
+
queue_depth: depth.to_i, queue_latency: (latency.to_i / 1000.0).round(1) }
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Deepest depth reported for each queue across `samples`, most backed up
|
|
40
|
+
# first. Every worker reports the same shared queue, so the readings are
|
|
41
|
+
# merged with max, not summed.
|
|
42
|
+
def self.queue_depths(samples)
|
|
43
|
+
depths = samples.each_with_object({}) do |sample, out|
|
|
44
|
+
# A process without a job backend reports queues: nil, not {}.
|
|
45
|
+
(sample.detail["queues"] || {}).each { |queue, depth| out[queue] = [ out[queue].to_i, depth.to_i ].max }
|
|
46
|
+
end
|
|
47
|
+
depths.sort_by { |_queue, depth| -depth }.map { |queue, depth| { queue: queue, depth: depth } }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# The recurring task keys Solid Queue is running, from the newest live
|
|
51
|
+
# sample that carries a manifest (the gem sends one on every health
|
|
52
|
+
# sample). nil when no live process has reported one: an older gem, or
|
|
53
|
+
# nothing alive to ask.
|
|
54
|
+
def self.recurring_task_keys(since: LIVE_WINDOW.ago)
|
|
55
|
+
manifest = where(sampled_at: since..).where("json_extract(detail, '$.recurring_tasks') IS NOT NULL")
|
|
56
|
+
.recent.pick(Arel.sql("json_extract(detail, '$.recurring_tasks')"))
|
|
57
|
+
manifest && JSON.parse(manifest).keys
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def self.bucket_expression(bucket)
|
|
61
|
+
seconds = bucket.to_i
|
|
62
|
+
Arel.sql("strftime('%Y-%m-%dT%H:%M:00Z', (strftime('%s', sampled_at) / #{seconds}) * #{seconds}, 'unixepoch')")
|
|
63
|
+
end
|
|
64
|
+
private_class_method :bucket_expression
|
|
65
|
+
|
|
66
|
+
def thread_utilisation
|
|
67
|
+
return nil if threads_max.to_i.zero?
|
|
68
|
+
threads_busy.to_f / threads_max
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Railwatch
|
|
4
|
+
module Telemetry
|
|
5
|
+
# One row per batch written into the telemetry database. In embedded mode
|
|
6
|
+
# it is also the delivery ledger: `batch_id` is the reporter's stable id
|
|
7
|
+
# for the batch (unique, so a replay is a no-op) and `followups` is the
|
|
8
|
+
# work the batch still owes outside its own transaction, nil once done.
|
|
9
|
+
class IngestBatch < TelemetryRecord
|
|
10
|
+
scope :recent, -> { order(received_at: :desc) }
|
|
11
|
+
scope :with_pending_followups, -> { where.not(followups: nil).order(:received_at) }
|
|
12
|
+
|
|
13
|
+
def self.committed(batch_id)
|
|
14
|
+
batch_id.present? ? find_by(batch_id: batch_id) : nil
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Runs the follow-ups a batch left behind and clears them. Safe to run
|
|
18
|
+
# more than once: the grouping commits a FollowupReceipt per (batch,
|
|
19
|
+
# group) in the railwatch database together with the occurrence count,
|
|
20
|
+
# so a second run finds the receipts and counts nothing. Clearing the
|
|
21
|
+
# outbox here is only what stops the maintenance drain from picking the
|
|
22
|
+
# row up again; it is not what makes the replay safe.
|
|
23
|
+
def drain_followups!(environment)
|
|
24
|
+
work = followups || {}
|
|
25
|
+
ids = Array(work["group_exception_ids"])
|
|
26
|
+
GroupExceptionsJob.new.perform(environment, ids, batch_id: batch_id) if ids.any?
|
|
27
|
+
update_columns(followups: nil)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Railwatch
|
|
4
|
+
module Telemetry
|
|
5
|
+
class LlmCall < TelemetryRecord
|
|
6
|
+
include Child
|
|
7
|
+
|
|
8
|
+
# A tool invocation is a call in the same pipeline, so it shares this
|
|
9
|
+
# table; it just has a name where a model call has a model.
|
|
10
|
+
TOOL = "tool"
|
|
11
|
+
|
|
12
|
+
scope :models, -> { where.not(operation: TOOL) }
|
|
13
|
+
scope :tools, -> { where(operation: TOOL) }
|
|
14
|
+
|
|
15
|
+
NANOS_PER_DOLLAR = 1_000_000_000.0
|
|
16
|
+
|
|
17
|
+
# Dollars, or nil when the call is unpriced. Callers must render the
|
|
18
|
+
# difference: an unpriced call is not a free one.
|
|
19
|
+
def cost
|
|
20
|
+
cost_nanos && cost_nanos / NANOS_PER_DOLLAR
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# What the app paid for, which is the count a bill is drawn from.
|
|
24
|
+
def total_tokens
|
|
25
|
+
[ input_tokens, output_tokens, cache_read_tokens, cache_write_tokens ].compact.sum
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def timeline_label
|
|
29
|
+
tool? ? tool_name.to_s : "#{provider}/#{model}"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def tool?
|
|
33
|
+
operation == TOOL
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Railwatch
|
|
4
|
+
module Telemetry
|
|
5
|
+
class Log < TelemetryRecord
|
|
6
|
+
include Child
|
|
7
|
+
|
|
8
|
+
# Wraps each hit inside a snippet. One character for both ends so the
|
|
9
|
+
# frontend can split on it and mark the odd-numbered pieces; U+001F is
|
|
10
|
+
# not something a log line can realistically contain.
|
|
11
|
+
SNIPPET_MARK = "\u001F"
|
|
12
|
+
|
|
13
|
+
# "quoted phrase" | -excluded | bare, in the order they were typed.
|
|
14
|
+
TERM = /-?"[^"]*"|\S+/
|
|
15
|
+
|
|
16
|
+
scope :fts, ->(text) {
|
|
17
|
+
next none if text.to_s.strip.empty?
|
|
18
|
+
next where("message LIKE ?", "%#{sanitize_sql_like(text)}%") unless fts_available?
|
|
19
|
+
match = match_expression(text)
|
|
20
|
+
match ? where("logs.id IN (SELECT rowid FROM logs_fts WHERE logs_fts MATCH ?)", match) : none
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
class << self
|
|
24
|
+
# Free text -> an FTS5 MATCH expression. Quoted phrases stay whole,
|
|
25
|
+
# every other word becomes a prefix token so "widg" finds "widgets",
|
|
26
|
+
# and a leading "-" excludes -- but only when something positive is
|
|
27
|
+
# left to exclude from, since FTS5's NOT needs a left operand. Every
|
|
28
|
+
# term is emitted as a quoted string, which is what keeps FTS5 syntax
|
|
29
|
+
# (NEAR, *, :, parens) inert instead of raising a "syntax error".
|
|
30
|
+
# Returns nil when the text has nothing searchable in it.
|
|
31
|
+
def match_expression(text)
|
|
32
|
+
terms = text.to_s.scan(TERM).filter_map { |term| parse_term(term) }
|
|
33
|
+
positive = terms.reject(&:first).map(&:last)
|
|
34
|
+
negative = terms.select(&:first).map(&:last)
|
|
35
|
+
if positive.empty?
|
|
36
|
+
positive = negative
|
|
37
|
+
negative = []
|
|
38
|
+
end
|
|
39
|
+
return nil if positive.empty?
|
|
40
|
+
[ positive.join(" "), *negative.map { |term| "NOT #{term}" } ].join(" ")
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# id => message with SNIPPET_MARK around each hit, for the rows the
|
|
44
|
+
# page is about to render. Empty unless we actually ran an FTS query.
|
|
45
|
+
def fts_snippets(ids, text)
|
|
46
|
+
return {} if ids.empty? || !fts_available?
|
|
47
|
+
match = match_expression(text)
|
|
48
|
+
return {} if match.nil?
|
|
49
|
+
sql = sanitize_sql_array([ "SELECT rowid, snippet(logs_fts, 0, ?, ?, ?, 24) FROM logs_fts WHERE logs_fts MATCH ? AND rowid IN (?)",
|
|
50
|
+
SNIPPET_MARK, SNIPPET_MARK, "…", match, ids ])
|
|
51
|
+
connection.select_rows(sql).to_h
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Postgres telemetry databases (SCOPE.md keeps them supported) have no
|
|
55
|
+
# logs_fts, and neither does a database created before the index was
|
|
56
|
+
# added. Every telemetry database is built from the same schema, so
|
|
57
|
+
# this is a process-wide fact rather than a per-environment one.
|
|
58
|
+
def fts_available?
|
|
59
|
+
return @fts_available if defined?(@fts_available)
|
|
60
|
+
@fts_available = connection.adapter_name.match?(/sqlite/i) &&
|
|
61
|
+
connection.select_value("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'logs_fts'").present?
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
# "term" -> [negated?, fts5 term], or nil if there is nothing left of
|
|
67
|
+
# it once the quotes and the minus sign come off.
|
|
68
|
+
def parse_term(term)
|
|
69
|
+
negated = term.start_with?("-") && term.length > 1
|
|
70
|
+
body = negated ? term[1..] : term
|
|
71
|
+
phrase = body.start_with?('"') && body.end_with?('"') && body.length > 1
|
|
72
|
+
body = body[1..-2].to_s if phrase
|
|
73
|
+
return nil unless body.match?(/[[:alnum:]]/)
|
|
74
|
+
[ negated, phrase ? quote(body) : "#{quote(body)}*" ]
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def quote(term) = %("#{term.gsub('"', '""')}")
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def timeline_label
|
|
81
|
+
message.to_s.first(120)
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|