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,305 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Railwatch
|
|
4
|
+
module Ingest
|
|
5
|
+
# Writes one batch into the environment's telemetry database: rows are
|
|
6
|
+
# grouped by table and bulk-inserted inside a single transaction, then the
|
|
7
|
+
# follow-up work (issue grouping, rollups) is enqueued. Unknown or
|
|
8
|
+
# malformed records are counted as rejected, never raised.
|
|
9
|
+
class Batch
|
|
10
|
+
Result = Struct.new(:accepted, :rejected, :rejections, keyword_init: true) do
|
|
11
|
+
def to_h = { accepted: accepted, rejected: rejected, rejections: rejections.first(10) }
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# embedded: this batch is being written in the monitored application's own
|
|
15
|
+
# process rather than served over HTTP. Quota accounting is a platform
|
|
16
|
+
# concern, and count_events! writes the PRIMARY database on every batch --
|
|
17
|
+
# exactly the customer-database write an embedded install must not make.
|
|
18
|
+
# The live broadcast stays (it is sub-millisecond and throttled to once
|
|
19
|
+
# every 2s) but must not be able to break ingest in an app that has no
|
|
20
|
+
# Action Cable adapter configured.
|
|
21
|
+
def initialize(environment, records, dropped_by_client: 0, backpressure_factor: nil, bytes: 0, gem_version: nil,
|
|
22
|
+
received_at: nil, embedded: false, batch_id: nil)
|
|
23
|
+
@embedded = embedded
|
|
24
|
+
@batch_id = batch_id
|
|
25
|
+
@environment = environment
|
|
26
|
+
@records = records
|
|
27
|
+
@dropped_by_client = dropped_by_client
|
|
28
|
+
@backpressure_factor = normalize_backpressure_factor(backpressure_factor)
|
|
29
|
+
@bytes = bytes
|
|
30
|
+
@gem_version = gem_version
|
|
31
|
+
@received_at = received_at || Time.current
|
|
32
|
+
@rejections = []
|
|
33
|
+
@truncations = []
|
|
34
|
+
@counts = Hash.new(0)
|
|
35
|
+
@rows_by_class = Hash.new { |h, k| h[k] = [] }
|
|
36
|
+
@people = []
|
|
37
|
+
@rollup_buckets = Set.new
|
|
38
|
+
@session_buckets = Set.new
|
|
39
|
+
@bucket_cache = {}
|
|
40
|
+
@exception_ids = []
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# The ledger row for a batch id that has already been written, or nil.
|
|
44
|
+
# The row is created inside the batch's transaction, so seeing it is
|
|
45
|
+
# seeing the commit. Callers run this inside environment.with_telemetry.
|
|
46
|
+
def self.committed(batch_id)
|
|
47
|
+
Telemetry::IngestBatch.committed(batch_id)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def write!
|
|
51
|
+
map_all
|
|
52
|
+
log_truncations
|
|
53
|
+
accepted = 0
|
|
54
|
+
Railwatch.span("ingest.write", records: @records.size) do
|
|
55
|
+
@environment.with_telemetry do
|
|
56
|
+
TelemetryRecord.transaction do
|
|
57
|
+
writer = Ingest::Writer.new(@rows_by_class)
|
|
58
|
+
@exception_ids = writer.write!
|
|
59
|
+
link_profiles!
|
|
60
|
+
absorb_rollups!(writer) if @embedded
|
|
61
|
+
accepted += @rows_by_class.values.sum(&:size)
|
|
62
|
+
Telemetry::Person.touch_all(@people.map { |rec| [ rec, Time.at(rec["timestamp"].to_f).utc ] })
|
|
63
|
+
accepted += @people.size
|
|
64
|
+
@ledger = Telemetry::IngestBatch.create!(received_at: @received_at, accepted: accepted, rejected: @rejections.size,
|
|
65
|
+
dropped_by_client: @dropped_by_client, backpressure_factor: @backpressure_factor,
|
|
66
|
+
bytes: @bytes, gem_version: @gem_version,
|
|
67
|
+
counts_by_type: @counts, rejections: @rejections.first(20),
|
|
68
|
+
batch_id: @batch_id, followups: followups)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
if @embedded
|
|
74
|
+
touch_last_seen
|
|
75
|
+
else
|
|
76
|
+
@environment.count_events!(accepted)
|
|
77
|
+
end
|
|
78
|
+
enqueue_followups
|
|
79
|
+
broadcast_live
|
|
80
|
+
Result.new(accepted: accepted, rejected: @rejections.size, rejections: @rejections)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# What this batch still owes once its own transaction has committed, in
|
|
84
|
+
# embedded mode: the work that writes the railwatch database and so
|
|
85
|
+
# cannot share the telemetry transaction. Recorded on the ledger row in
|
|
86
|
+
# that same transaction and cleared when done, so a crash in between
|
|
87
|
+
# leaves a row Railwatch::Maintenance can finish rather than a batch
|
|
88
|
+
# whose exceptions never become issues. nil (nothing owed) otherwise.
|
|
89
|
+
def followups
|
|
90
|
+
return nil unless @embedded && @exception_ids.any?
|
|
91
|
+
|
|
92
|
+
{ "group_exception_ids" => @exception_ids }
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
private
|
|
96
|
+
|
|
97
|
+
ROLLED_UP = %w[request job_attempt scheduled_task command channel_action query outgoing_request cache_event mail visit notification span llm_call].freeze
|
|
98
|
+
MAX_PAST_AGE = 30.days
|
|
99
|
+
MAX_FUTURE_AGE = 1.hour
|
|
100
|
+
|
|
101
|
+
def normalize_backpressure_factor(value)
|
|
102
|
+
factor = Float(value, exception: false)
|
|
103
|
+
return 1.0 unless factor&.finite?
|
|
104
|
+
|
|
105
|
+
[ factor, 1.0 ].max
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# A plain Concurrent::Map keyed by environment id throttles across every
|
|
109
|
+
# thread writing batches in this process -- no need for Rails.cache
|
|
110
|
+
# (which is :null_store in test anyway, so it wouldn't throttle there at
|
|
111
|
+
# all) or a database column. It does not coordinate across separate
|
|
112
|
+
# Puma *worker processes*: a host running cluster mode broadcasts up to
|
|
113
|
+
# once per 2s per worker instead of once globally, which is fine since
|
|
114
|
+
# these broadcasts are only a "data changed, go refetch" ping, not the
|
|
115
|
+
# data itself.
|
|
116
|
+
LAST_BROADCAST_AT = Concurrent::Map.new
|
|
117
|
+
THROTTLE_WINDOW = 2.seconds
|
|
118
|
+
|
|
119
|
+
# RollupJob's limits_concurrency only discards an enqueue while a run
|
|
120
|
+
# for that bucket is queued or running; with a batch landing every
|
|
121
|
+
# second, the next enqueue arrives the moment the previous run finishes,
|
|
122
|
+
# and the rollups worker recomputed one busy hour 1,200 times an hour.
|
|
123
|
+
# One enqueue per bucket per window from here; RollupCatchupJob sweeps
|
|
124
|
+
# the current and previous hour every 5 minutes regardless.
|
|
125
|
+
LAST_ROLLUP_ENQUEUE_AT = Concurrent::Map.new
|
|
126
|
+
ROLLUP_ENQUEUE_WINDOW = 60.seconds
|
|
127
|
+
|
|
128
|
+
def map_all
|
|
129
|
+
@records.each do |rec|
|
|
130
|
+
if @embedded
|
|
131
|
+
# These two the mapper's own dispatch depends on; the rest of
|
|
132
|
+
# validate_record! is the untrusted-input check that an in-process
|
|
133
|
+
# batch does not need.
|
|
134
|
+
raise TypeError, "record must be an object" unless rec.is_a?(Hash)
|
|
135
|
+
raise TypeError, "t must be a string" unless rec["t"].is_a?(String)
|
|
136
|
+
else
|
|
137
|
+
Ingest::Mapper.validate_record!(rec)
|
|
138
|
+
end
|
|
139
|
+
type = rec["t"]
|
|
140
|
+
unless timestamp_in_range?(rec["timestamp"])
|
|
141
|
+
reject(rec, "timestamp out of range")
|
|
142
|
+
next
|
|
143
|
+
end
|
|
144
|
+
if type == "user"
|
|
145
|
+
@people << Ingest::Mapper.person_record(rec)
|
|
146
|
+
@counts[type] += 1
|
|
147
|
+
next
|
|
148
|
+
end
|
|
149
|
+
row = Ingest::Mapper.row_for(rec, truncations: @truncations, validate_identifiers: !@embedded, trusted: @embedded)
|
|
150
|
+
if row.nil?
|
|
151
|
+
reject(rec, "unknown type")
|
|
152
|
+
next
|
|
153
|
+
end
|
|
154
|
+
klass, attrs = row
|
|
155
|
+
@rows_by_class[klass] << attrs
|
|
156
|
+
@counts[type] += 1
|
|
157
|
+
if ROLLED_UP.include?(type)
|
|
158
|
+
@rollup_buckets << bucket_for(rec["timestamp"])
|
|
159
|
+
elsif type == "session"
|
|
160
|
+
# Sessions get their own hourly table rather than the t-digest
|
|
161
|
+
# rollups: what release health needs is each session's worst status,
|
|
162
|
+
# not duration percentiles.
|
|
163
|
+
@session_buckets << bucket_for(rec["timestamp"])
|
|
164
|
+
end
|
|
165
|
+
rescue ArgumentError, TypeError, KeyError, NoMethodError, RangeError, JSON::GeneratorError => e
|
|
166
|
+
reject(rec, "#{e.class}: #{e.message}")
|
|
167
|
+
rescue StandardError => e
|
|
168
|
+
Rails.error.report(e, handled: true, context: { ingest_record_type: record_type(rec) })
|
|
169
|
+
reject(rec, "invalid record")
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def timestamp_in_range?(value)
|
|
174
|
+
raise TypeError, "timestamp must be a number" unless value.is_a?(Numeric)
|
|
175
|
+
|
|
176
|
+
timestamp = value.to_f
|
|
177
|
+
raise RangeError, "timestamp is not finite" unless timestamp.finite?
|
|
178
|
+
|
|
179
|
+
min, max = timestamp_bounds
|
|
180
|
+
timestamp.between?(min, max)
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# Time - 30.days walks the calendar through ActiveSupport::Duration, and
|
|
184
|
+
# recomputing both ends per record was 13% of an embedded batch's write.
|
|
185
|
+
# The window is fixed for the batch; compute it once.
|
|
186
|
+
def timestamp_bounds
|
|
187
|
+
@timestamp_bounds ||= [ (@received_at - MAX_PAST_AGE).to_f, (@received_at + MAX_FUTURE_AGE).to_f ]
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def bucket_for(value)
|
|
191
|
+
seconds = (Float(value) * 1_000_000).round / 1_000_000
|
|
192
|
+
hour = seconds - (seconds % 1.hour)
|
|
193
|
+
@bucket_cache[hour] ||= Telemetry::Rollup.bucket_for(Time.zone.at(hour))
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# executions.profile_id is denormalised so a request list or detail page
|
|
197
|
+
# can tell "this one was profiled" without touching the profiles table.
|
|
198
|
+
# The writer does not hand profile ids back, so the freshly inserted
|
|
199
|
+
# rows are matched by execution_id in one correlated UPDATE. The profile
|
|
200
|
+
# and its execution usually arrive in the same batch, but not always --
|
|
201
|
+
# this only links the ones present here; a late profile links on its own
|
|
202
|
+
# batch, a late execution stays unlinked until the page falls back to
|
|
203
|
+
# looking the profile up by execution_id.
|
|
204
|
+
def link_profiles!
|
|
205
|
+
execution_ids = @rows_by_class.fetch(Telemetry::Profile, []).filter_map { |attrs| attrs[:execution_id] }.uniq
|
|
206
|
+
return if execution_ids.empty?
|
|
207
|
+
Telemetry::Execution.where(execution_id: execution_ids)
|
|
208
|
+
.update_all("profile_id = (SELECT MAX(profiles.id) FROM profiles WHERE profiles.execution_id = executions.execution_id)")
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def reject(rec, reason)
|
|
212
|
+
@rejections << { type: record_type(rec), reason: reason.to_s.first(200) }
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def record_type(rec)
|
|
216
|
+
rec.is_a?(Hash) && rec["t"].is_a?(String) ? rec["t"].first(64) : nil
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def log_truncations
|
|
220
|
+
return if @truncations.empty?
|
|
221
|
+
|
|
222
|
+
counts = @truncations.tally.sort.to_h
|
|
223
|
+
Rails.logger.info("ingest truncated #{@truncations.size} field(s) for #{@environment.slug}: #{counts.to_json}")
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# Embedded: nothing is enqueued, ever. The host may have no worker, and
|
|
227
|
+
# its queue adapter may be its primary database, which this gem never
|
|
228
|
+
# writes. Exceptions are grouped here, right after the batch commits;
|
|
229
|
+
# rollups were folded in by absorb_rollups!; release health and the
|
|
230
|
+
# detectors run from Railwatch::Maintenance on its own clock.
|
|
231
|
+
def enqueue_followups
|
|
232
|
+
return group_exceptions_now if @embedded
|
|
233
|
+
|
|
234
|
+
GroupExceptionsJob.perform_later(@environment, @exception_ids) if @exception_ids.any?
|
|
235
|
+
@rollup_buckets.each { |bucket| RollupJob.perform_later(@environment, bucket) if rollup_due?(bucket) }
|
|
236
|
+
@session_buckets.each { |bucket| ReleaseHealthRollupJob.perform_later(@environment, bucket) }
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
# Outside the telemetry transaction (it writes the railwatch database),
|
|
240
|
+
# and a failure here must not fail a batch that has already committed:
|
|
241
|
+
# the ledger row keeps the follow-ups, and the maintenance clock drains
|
|
242
|
+
# them on its next tick.
|
|
243
|
+
def group_exceptions_now
|
|
244
|
+
return if @exception_ids.empty?
|
|
245
|
+
|
|
246
|
+
@environment.with_telemetry { @ledger.drain_followups!(@environment) }
|
|
247
|
+
rescue StandardError => e
|
|
248
|
+
Rails.error.report(e, handled: true, context: { ingest_group_exceptions: @environment.slug })
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
# In-process there is no worker to recompute the hour and someone may
|
|
252
|
+
# be watching this one environment, so the batch folds its own rows
|
|
253
|
+
# into the hourly rollups as it lands (Ingest::RollupAbsorber). Inside
|
|
254
|
+
# the batch transaction: the raw rows and their rollup move together.
|
|
255
|
+
def absorb_rollups!(writer)
|
|
256
|
+
Railwatch.span("ingest.rollup_absorb") do
|
|
257
|
+
Ingest::RollupAbsorber.new(@rows_by_class, query_shapes: writer.query_shapes || {}).absorb!
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
# Embedded installs skip quota accounting, but last_seen_at still drives
|
|
262
|
+
# the "last seen" display and SilentHostCheckJob. One primary-database
|
|
263
|
+
# write per minute, not one per batch.
|
|
264
|
+
LAST_SEEN_TOUCH_AT = Concurrent::Map.new
|
|
265
|
+
LAST_SEEN_TOUCH_WINDOW = 60.seconds
|
|
266
|
+
|
|
267
|
+
def touch_last_seen
|
|
268
|
+
now = Time.current
|
|
269
|
+
last = LAST_SEEN_TOUCH_AT[@environment.id]
|
|
270
|
+
return if last && now - last < LAST_SEEN_TOUCH_WINDOW
|
|
271
|
+
|
|
272
|
+
LAST_SEEN_TOUCH_AT[@environment.id] = now
|
|
273
|
+
@environment.update_columns(last_seen_at: now)
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
def rollup_due?(bucket)
|
|
277
|
+
key = [ @environment.id, bucket.to_i ]
|
|
278
|
+
now = Time.current
|
|
279
|
+
last = LAST_ROLLUP_ENQUEUE_AT[key]
|
|
280
|
+
return false if last && now - last < ROLLUP_ENQUEUE_WINDOW
|
|
281
|
+
|
|
282
|
+
LAST_ROLLUP_ENQUEUE_AT[key] = now
|
|
283
|
+
true
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
def broadcast_live
|
|
287
|
+
return unless defined?(::ActionCable)
|
|
288
|
+
|
|
289
|
+
now = Time.current
|
|
290
|
+
last = LAST_BROADCAST_AT[@environment.id]
|
|
291
|
+
return if last && now - last < THROTTLE_WINDOW
|
|
292
|
+
|
|
293
|
+
LAST_BROADCAST_AT[@environment.id] = now
|
|
294
|
+
ActionCable.server.broadcast("environment_#{@environment.id}", { event: "ingested", at: now.iso8601, counts: @counts })
|
|
295
|
+
rescue StandardError, LoadError => e
|
|
296
|
+
# A live refresh ping is not worth failing a written batch over.
|
|
297
|
+
# LoadError too: a host whose production cable adapter is redis with
|
|
298
|
+
# no redis gem raises Gem::LoadError from inside the broadcast, and
|
|
299
|
+
# in the writer that took the whole process down with it (Puma
|
|
300
|
+
# restarted it, every batch retried, every retry did it again).
|
|
301
|
+
Rails.error.report(e, handled: true, context: { ingest_broadcast: @environment.slug })
|
|
302
|
+
end
|
|
303
|
+
end
|
|
304
|
+
end
|
|
305
|
+
end
|