railwatch 0.1.3 → 0.2.0.pre1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +231 -0
- data/README.md +10 -0
- data/app/channels/railwatch/environment_channel.rb +28 -0
- data/app/controllers/concerns/railwatch/telemetry_identity.rb +25 -0
- data/app/controllers/railwatch/alerts_controller.rb +48 -0
- data/app/controllers/railwatch/anomaly_rules_controller.rb +31 -0
- data/app/controllers/railwatch/attachments_controller.rb +22 -0
- data/app/controllers/railwatch/beacon_controller.rb +67 -7
- data/app/controllers/railwatch/broadcasts_controller.rb +15 -0
- data/app/controllers/railwatch/cache_events_controller.rb +30 -0
- data/app/controllers/railwatch/commands_controller.rb +16 -0
- data/app/controllers/railwatch/comments_controller.rb +11 -0
- data/app/controllers/railwatch/dashboard_controller.rb +69 -0
- data/app/controllers/railwatch/deploys_controller.rb +34 -0
- data/app/controllers/railwatch/deprecations_controller.rb +54 -0
- data/app/controllers/railwatch/environment_scoped.rb +99 -0
- data/app/controllers/railwatch/exceptions_controller.rb +53 -0
- data/app/controllers/railwatch/executions_controller.rb +13 -0
- data/app/controllers/railwatch/issues_controller.rb +258 -0
- data/app/controllers/railwatch/jobs_controller.rb +63 -0
- data/app/controllers/railwatch/llm_calls_controller.rb +124 -0
- data/app/controllers/railwatch/logs_controller.rb +40 -0
- data/app/controllers/railwatch/mails_controller.rb +19 -0
- data/app/controllers/railwatch/notifications_controller.rb +11 -0
- data/app/controllers/railwatch/outgoing_requests_controller.rb +19 -0
- data/app/controllers/railwatch/overview_controller.rb +38 -0
- data/app/controllers/railwatch/people_controller.rb +25 -0
- data/app/controllers/railwatch/processes_controller.rb +33 -0
- data/app/controllers/railwatch/profiles_controller.rb +50 -0
- data/app/controllers/railwatch/queries_controller.rb +59 -0
- data/app/controllers/railwatch/releases_controller.rb +75 -0
- data/app/controllers/railwatch/requests_controller.rb +55 -0
- data/app/controllers/railwatch/saved_views_controller.rb +53 -0
- data/app/controllers/railwatch/scheduled_tasks_controller.rb +45 -0
- data/app/controllers/railwatch/spans_controller.rb +47 -0
- data/app/controllers/railwatch/storage_ops_controller.rb +15 -0
- data/app/controllers/railwatch/tenants_controller.rb +44 -0
- data/app/controllers/railwatch/thresholds_controller.rb +40 -0
- data/app/controllers/railwatch/traces_controller.rb +72 -0
- data/app/controllers/railwatch/transactions_controller.rb +21 -0
- data/app/controllers/railwatch/view_renders_controller.rb +28 -0
- data/app/controllers/railwatch/visits_controller.rb +53 -0
- data/app/helpers/railwatch/assets_helper.rb +52 -0
- data/app/jobs/railwatch/anomaly_scan_job.rb +11 -0
- data/app/jobs/railwatch/application_job.rb +7 -0
- data/app/jobs/railwatch/auto_resolve_issues_job.rb +19 -0
- data/app/jobs/railwatch/check_scheduled_tasks_job.rb +113 -0
- data/app/jobs/railwatch/detect_anomalies_job.rb +171 -0
- data/app/jobs/railwatch/detect_performance_issues_job.rb +85 -0
- data/app/jobs/railwatch/group_exceptions_job.rb +91 -0
- data/app/jobs/railwatch/optimize_telemetry_job.rb +23 -0
- data/app/jobs/railwatch/performance_scan_job.rb +11 -0
- data/app/jobs/railwatch/prune_telemetry_job.rb +110 -0
- data/app/jobs/railwatch/release_health_rollup_job.rb +64 -0
- data/app/jobs/railwatch/rollup_catchup_job.rb +21 -0
- data/app/jobs/railwatch/rollup_job.rb +130 -0
- data/app/jobs/railwatch/scheduled_task_scan_job.rb +11 -0
- data/app/models/concerns/railwatch/detection_snapshotting.rb +20 -0
- data/app/models/railwatch/alert.rb +229 -0
- data/app/models/railwatch/alert_rule.rb +121 -0
- data/app/models/railwatch/anomaly_rule.rb +33 -0
- data/app/models/railwatch/application.rb +44 -0
- data/app/models/railwatch/application_record.rb +33 -0
- data/app/models/railwatch/comment.rb +36 -0
- data/app/models/railwatch/deploy.rb +67 -0
- data/app/models/railwatch/environment.rb +54 -0
- data/app/models/railwatch/execution_presenter.rb +185 -0
- data/app/models/railwatch/filter_query.rb +143 -0
- data/app/models/railwatch/followup_receipt.rb +27 -0
- data/app/models/railwatch/ingest/batch.rb +305 -0
- data/app/models/railwatch/ingest/mapper.rb +575 -0
- data/app/models/railwatch/ingest/payload.rb +96 -0
- data/app/models/railwatch/ingest/rollup_absorber.rb +170 -0
- data/app/models/railwatch/ingest/writer.rb +137 -0
- data/app/models/railwatch/issue.rb +277 -0
- data/app/models/railwatch/issue_activity.rb +23 -0
- data/app/models/railwatch/issue_detection_presenter.rb +309 -0
- data/app/models/railwatch/issue_detection_snapshot.rb +77 -0
- data/app/models/railwatch/maintenance_task.rb +53 -0
- data/app/models/railwatch/saved_view.rb +63 -0
- data/app/models/railwatch/telemetry/aggregations.rb +116 -0
- data/app/models/railwatch/telemetry/attachment.rb +31 -0
- data/app/models/railwatch/telemetry/bounded_gzip.rb +101 -0
- data/app/models/railwatch/telemetry/broadcast.rb +13 -0
- data/app/models/railwatch/telemetry/cache_event.rb +16 -0
- data/app/models/railwatch/telemetry/child.rb +53 -0
- data/app/models/railwatch/telemetry/cursor_page.rb +99 -0
- data/app/models/railwatch/telemetry/deprecation.rb +13 -0
- data/app/models/railwatch/telemetry/enqueued_job.rb +13 -0
- data/app/models/railwatch/telemetry/exception.rb +21 -0
- data/app/models/railwatch/telemetry/execution.rb +71 -0
- data/app/models/railwatch/telemetry/health_sample.rb +72 -0
- data/app/models/railwatch/telemetry/ingest_batch.rb +31 -0
- data/app/models/railwatch/telemetry/llm_call.rb +37 -0
- data/app/models/railwatch/telemetry/log.rb +85 -0
- data/app/models/railwatch/telemetry/mail.rb +13 -0
- data/app/models/railwatch/telemetry/n_plus_one.rb +92 -0
- data/app/models/railwatch/telemetry/notification.rb +13 -0
- data/app/models/railwatch/telemetry/outgoing_request.rb +13 -0
- data/app/models/railwatch/telemetry/person.rb +50 -0
- data/app/models/railwatch/telemetry/process.rb +10 -0
- data/app/models/railwatch/telemetry/profile.rb +37 -0
- data/app/models/railwatch/telemetry/query.rb +50 -0
- data/app/models/railwatch/telemetry/query_shape.rb +45 -0
- data/app/models/railwatch/telemetry/release_health.rb +63 -0
- data/app/models/railwatch/telemetry/rollup.rb +78 -0
- data/app/models/railwatch/telemetry/session.rb +24 -0
- data/app/models/railwatch/telemetry/span.rb +19 -0
- data/app/models/railwatch/telemetry/storage_op.rb +13 -0
- data/app/models/railwatch/telemetry/tenant.rb +221 -0
- data/app/models/railwatch/telemetry/transaction.rb +13 -0
- data/app/models/railwatch/telemetry/view_render.rb +13 -0
- data/app/models/railwatch/telemetry/visit.rb +24 -0
- data/app/models/railwatch/telemetry_record.rb +57 -0
- data/app/models/railwatch/threshold.rb +29 -0
- data/app/models/railwatch/user.rb +47 -0
- data/app/models/railwatch/viewer.rb +13 -0
- data/app/views/layouts/railwatch/dashboard.html.erb +25 -0
- data/config/routes.rb +59 -0
- data/db/railwatch_migrate/20260916000000_create_railwatch_tables.rb +151 -0
- data/db/railwatch_migrate/20260917000000_create_railwatch_maintenance_tasks.rb +18 -0
- data/db/railwatch_migrate/20260917120000_create_railwatch_followup_receipts.rb +20 -0
- data/db/railwatch_telemetry_migrate/20260903000001_create_telemetry.rb +481 -0
- data/db/railwatch_telemetry_migrate/20260903000002_rename_tenant_to_app_tenant.rb +14 -0
- data/db/railwatch_telemetry_migrate/20260903000003_add_statement_count_to_transactions.rb +9 -0
- data/db/railwatch_telemetry_migrate/20260903000004_add_role_and_channel.rb +10 -0
- data/db/railwatch_telemetry_migrate/20260903000005_add_locals_to_exceptions.rb +9 -0
- data/db/railwatch_telemetry_migrate/20260903000006_add_spans_health_vitals_and_fts.rb +82 -0
- data/db/railwatch_telemetry_migrate/20260903000007_rename_span_attributes_to_payload.rb +10 -0
- data/db/railwatch_telemetry_migrate/20260903000008_add_profiles_and_attachments.rb +60 -0
- data/db/railwatch_telemetry_migrate/20260903000009_add_truncated_to_attachments.rb +9 -0
- data/db/railwatch_telemetry_migrate/20260903000010_create_sessions_and_release_health.rb +51 -0
- data/db/railwatch_telemetry_migrate/20260903000011_add_fingerprint_to_exceptions.rb +11 -0
- data/db/railwatch_telemetry_migrate/20260904000012_add_failed_to_broadcasts.rb +10 -0
- data/db/railwatch_telemetry_migrate/20260904010000_add_filter_cursor_indexes.rb +37 -0
- data/db/railwatch_telemetry_migrate/20260904020000_add_n_plus_ones_execution_id_index.rb +11 -0
- data/db/railwatch_telemetry_migrate/20260904120000_create_query_shapes.rb +15 -0
- data/db/railwatch_telemetry_migrate/20260906120000_add_backpressure_factor_to_ingest_batches.rb +9 -0
- data/db/railwatch_telemetry_migrate/20260907000000_rename_lantern_version_on_processes.rb +10 -0
- data/db/railwatch_telemetry_migrate/20260913000000_rename_nightrail_version_on_processes.rb +9 -0
- data/db/railwatch_telemetry_migrate/20260914000000_drop_orphan_durable_ingest_tables.rb +18 -0
- data/db/railwatch_telemetry_migrate/20260914010000_drop_orphan_durable_ingest_columns.rb +25 -0
- data/db/railwatch_telemetry_migrate/20260915000000_create_llm_calls.rb +55 -0
- data/db/railwatch_telemetry_migrate/20260915120000_add_detail_to_llm_calls.rb +21 -0
- data/db/railwatch_telemetry_migrate/20260915200000_add_explained_index_to_queries.rb +16 -0
- data/db/railwatch_telemetry_migrate/20260915210000_add_slowest_index_to_queries.rb +18 -0
- data/db/railwatch_telemetry_migrate/20260917010000_add_batch_ledger_to_ingest_batches.rb +16 -0
- data/docs/configuration.md +26 -0
- data/docs/embedded.md +342 -0
- data/docs/getting-started.md +5 -0
- data/docs/records.md +16 -1
- data/lib/generators/railwatch/install/install_generator.rb +222 -1
- data/lib/generators/railwatch/install/templates/{initializer.rb → initializer.rb.tt} +39 -0
- data/lib/generators/railwatch/install/templates/post-deploy +8 -0
- data/lib/puma/plugin/railwatch.rb +170 -0
- data/lib/railwatch/authentication.rb +83 -0
- data/lib/railwatch/configuration.rb +134 -3
- data/lib/railwatch/dashboard_assets.rb +45 -0
- data/lib/railwatch/embedded.rb +51 -0
- data/lib/railwatch/engine.rb +89 -1
- data/lib/railwatch/ingest_request_body_limit.rb +10 -0
- data/lib/railwatch/json_compat.rb +60 -0
- data/lib/railwatch/maintenance.rb +183 -0
- data/lib/railwatch/patches/runner_command.rb +21 -1
- data/lib/railwatch/record.rb +33 -8
- data/lib/railwatch/reporter.rb +16 -0
- data/lib/railwatch/subscribers/llm.rb +149 -4
- data/lib/railwatch/subscribers/process_info.rb +2 -1
- data/lib/railwatch/transport/local.rb +78 -0
- data/lib/railwatch/transport/socket.rb +183 -0
- data/lib/railwatch/version.rb +1 -1
- data/lib/railwatch/writer.rb +370 -0
- data/lib/railwatch.rb +38 -1
- data/lib/tasks/railwatch_tasks.rake +128 -0
- data/public/railwatch/assets/CommitMono-Bold-D6h61ieg.woff2 +0 -0
- data/public/railwatch/assets/CommitMono-Regular-zr8w7Obm.woff2 +0 -0
- data/public/railwatch/assets/Roboto-Black-auA4GeOK.woff2 +0 -0
- data/public/railwatch/assets/Roboto-Bold-CJLnO8j1.woff2 +0 -0
- data/public/railwatch/assets/Roboto-Medium-Cm2bwKpj.woff2 +0 -0
- data/public/railwatch/assets/Roboto-Regular-Chaq1-PV.woff2 +0 -0
- data/public/railwatch/assets/app-layout-yh-sPWgK.js +1 -0
- data/public/railwatch/assets/app-wordmark-nbjkzxwQ.js +1 -0
- data/public/railwatch/assets/appearance-CDuRvQTB.js +1 -0
- data/public/railwatch/assets/application-C_kpBdnf.css +1 -0
- data/public/railwatch/assets/arrow-up-DVtOGdVA.js +1 -0
- data/public/railwatch/assets/auth-layout-TCwPpS1K.js +1 -0
- data/public/railwatch/assets/badge-Daw4Hvr8.js +1 -0
- data/public/railwatch/assets/braces-DhFbHPsz.js +1 -0
- data/public/railwatch/assets/card-BX_3HXcJ.js +1 -0
- data/public/railwatch/assets/chart-B14-N9g7.js +39 -0
- data/public/railwatch/assets/chart-hover-Vy52H4uD.js +1 -0
- data/public/railwatch/assets/chart-panel-Cb4S_ej_.js +1 -0
- data/public/railwatch/assets/checkbox-D3aRSsBj.js +1 -0
- data/public/railwatch/assets/code-KvW8k7Jr.js +1 -0
- data/public/railwatch/assets/copy-DaQMJWoT.js +1 -0
- data/public/railwatch/assets/copy-block-BKFGd11J.js +1 -0
- data/public/railwatch/assets/copy-id-Djks1fXB.js +1 -0
- data/public/railwatch/assets/cursor-load-more-BXV0f1D_.js +1 -0
- data/public/railwatch/assets/data-table-iv1bdF6u.js +1 -0
- data/public/railwatch/assets/edit-BZc_Iawe.js +1 -0
- data/public/railwatch/assets/edit-DMKUF8Zi.js +8 -0
- data/public/railwatch/assets/edit-__9yJlO3.js +1 -0
- data/public/railwatch/assets/empty-state-6j_0AaQQ.js +1 -0
- data/public/railwatch/assets/env-layout-DrT8rO6P.js +1 -0
- data/public/railwatch/assets/execution-path-CzgBUi5e.js +1 -0
- data/public/railwatch/assets/filter-bar-9SU5NrzX.js +1 -0
- data/public/railwatch/assets/flamegraph-qcekju8V.js +2 -0
- data/public/railwatch/assets/format-B9SDkrWj.js +1 -0
- data/public/railwatch/assets/frames-Cyu7KMxZ.js +1 -0
- data/public/railwatch/assets/google-sign-in-button-DsTSfmzY.js +1 -0
- data/public/railwatch/assets/index-1ol1-QWI.js +1 -0
- data/public/railwatch/assets/index-5jI4aFzC.js +1 -0
- data/public/railwatch/assets/index-9KTrVnrc.js +1 -0
- data/public/railwatch/assets/index-B0-8lcTp.js +1 -0
- data/public/railwatch/assets/index-B7jjfNfO.js +1 -0
- data/public/railwatch/assets/index-BBchRy0M.js +1 -0
- data/public/railwatch/assets/index-BRiq3SNR.js +1 -0
- data/public/railwatch/assets/index-BgKj9xhr.js +1 -0
- data/public/railwatch/assets/index-BkTZqqOu.js +1 -0
- data/public/railwatch/assets/index-BprKx8QO.js +1 -0
- data/public/railwatch/assets/index-C3jzvPs3.js +1 -0
- data/public/railwatch/assets/index-Cbs6gGyQ.js +1 -0
- data/public/railwatch/assets/index-CdRZ6AWF.js +1 -0
- data/public/railwatch/assets/index-CeYKnapu.js +1 -0
- data/public/railwatch/assets/index-Cmlwy1-V.js +1 -0
- data/public/railwatch/assets/index-Cwx6058d.js +1 -0
- data/public/railwatch/assets/index-D4CSdbHv.js +1 -0
- data/public/railwatch/assets/index-DEFMSkdG.js +1 -0
- data/public/railwatch/assets/index-DFiHEBSh.js +1 -0
- data/public/railwatch/assets/index-DL4vWdWJ.js +1 -0
- data/public/railwatch/assets/index-DU9F5b5d.js +1 -0
- data/public/railwatch/assets/index-DaXgPcGL.js +1 -0
- data/public/railwatch/assets/index-DbtaU-EE.js +1 -0
- data/public/railwatch/assets/index-DeOe83F4.js +1 -0
- data/public/railwatch/assets/index-DiucHN4B.js +1 -0
- data/public/railwatch/assets/index-DlnR_l9o.js +1 -0
- data/public/railwatch/assets/index-DlumCsWY.js +2 -0
- data/public/railwatch/assets/index-DmRd7aIG.js +1 -0
- data/public/railwatch/assets/index-DxSh2UpM.js +1 -0
- data/public/railwatch/assets/index-MIMGuFNt.js +1 -0
- data/public/railwatch/assets/index-OqI59zPb.js +1 -0
- data/public/railwatch/assets/index-P4rC7IlX.js +1 -0
- data/public/railwatch/assets/index-gpPOcFWq.js +1 -0
- data/public/railwatch/assets/index-oVkururr.js +1 -0
- data/public/railwatch/assets/index-p9puqVge.js +1 -0
- data/public/railwatch/assets/inertia-TViv6kNv.js +97 -0
- data/public/railwatch/assets/input-error-LxImUkxv.js +1 -0
- data/public/railwatch/assets/json-viewer-Ar4cjPDW.js +1 -0
- data/public/railwatch/assets/klass-CJ-J4INB.js +1 -0
- data/public/railwatch/assets/label-COUKWqE_.js +1 -0
- data/public/railwatch/assets/layout-DNSLAkw_.js +1 -0
- data/public/railwatch/assets/live-dot-ChfUtY3p.js +41 -0
- data/public/railwatch/assets/nav-CNnDqPlm.js +1 -0
- data/public/railwatch/assets/new-84S8ZJq9.js +1 -0
- data/public/railwatch/assets/new-Be55nmt9.js +1 -0
- data/public/railwatch/assets/new-Bi_xQiIb.js +1 -0
- data/public/railwatch/assets/new-BvCT8TMg.js +1 -0
- data/public/railwatch/assets/new-D05SajFR.js +1 -0
- data/public/railwatch/assets/new-D4uewYC8.js +1 -0
- data/public/railwatch/assets/onboarding-CYZi5Cqc.js +1 -0
- data/public/railwatch/assets/origin-identity-6q1-CBts.js +1 -0
- data/public/railwatch/assets/percentile-picker-gFZCXtdb.js +1 -0
- data/public/railwatch/assets/relative-time-IOOgl5n2.js +1 -0
- data/public/railwatch/assets/release-health-DC8oc7uw.js +1 -0
- data/public/railwatch/assets/route-Dv6LAWvT.js +1 -0
- data/public/railwatch/assets/segmented-h1VdDTqE.js +1 -0
- data/public/railwatch/assets/select-_AJsUa7X.js +1 -0
- data/public/railwatch/assets/separator-BwwTYtCF.js +1 -0
- data/public/railwatch/assets/series-chart-DaFPefku.js +1 -0
- data/public/railwatch/assets/show-B7NCgkEo.js +1 -0
- data/public/railwatch/assets/show-BKqyKjBK.js +1 -0
- data/public/railwatch/assets/show-BM6X2Mpo.js +1 -0
- data/public/railwatch/assets/show-BNw4tN5q.js +1 -0
- data/public/railwatch/assets/show-BO3bnG5h.js +1 -0
- data/public/railwatch/assets/show-BhrAVAEA.js +1 -0
- data/public/railwatch/assets/show-C4Ltf5i9.js +2 -0
- data/public/railwatch/assets/show-C8sHalnw.js +1 -0
- data/public/railwatch/assets/show-CeTL4B37.js +2 -0
- data/public/railwatch/assets/show-CpfgV1jP.js +1 -0
- data/public/railwatch/assets/show-DACku6AD.js +3 -0
- data/public/railwatch/assets/show-DIOSGcXV.js +6 -0
- data/public/railwatch/assets/show-DQp_1n-B.js +1 -0
- data/public/railwatch/assets/show-DVNz46RI.js +1 -0
- data/public/railwatch/assets/show-DYteoYWW.js +1 -0
- data/public/railwatch/assets/show-DgSIoRvA.js +1 -0
- data/public/railwatch/assets/show-JxFtB4eK.js +2 -0
- data/public/railwatch/assets/sort-header-DpFzXblu.js +1 -0
- data/public/railwatch/assets/source-link-B2183i2-.js +1 -0
- data/public/railwatch/assets/sparkline-cell-C3-5vFkP.js +1 -0
- data/public/railwatch/assets/stat-s4RpOS9w.js +1 -0
- data/public/railwatch/assets/status-badge-8jVV-LA4.js +1 -0
- data/public/railwatch/assets/tenant-path-G-6u9A-o.js +1 -0
- data/public/railwatch/assets/text-link-DfsiaCcP.js +1 -0
- data/public/railwatch/assets/textarea-Dye72uP7.js +1 -0
- data/public/railwatch/assets/timeline-CD7WHnbo.js +1 -0
- data/public/railwatch/assets/transition-B_AW8rMK.js +5 -0
- data/public/railwatch/assets/use-clipboard-ColgLyQ2.js +1 -0
- data/public/railwatch/icon.png +0 -0
- data/public/railwatch/icon.svg +5 -0
- data/public/railwatch/manifest.json +2171 -0
- data/public/railwatch/rails-vite.json +1 -0
- metadata +313 -4
|
@@ -0,0 +1,575 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Railwatch
|
|
4
|
+
module Ingest
|
|
5
|
+
# Maps a wire record (the gem's flat hash) to a plain row: [klass, attrs],
|
|
6
|
+
# where attrs is a Hash of column name => value already serialized the way
|
|
7
|
+
# SQLite wants it on the wire (JSON columns as JSON strings, times as UTC
|
|
8
|
+
# "YYYY-MM-DD HH:MM:SS.ffffff" strings, booleans as 0/1, integers as
|
|
9
|
+
# Integer, strings truncated to the column's limit). Building an AR
|
|
10
|
+
# instance per record was measured at ~970ms of 1060ms for 300 records;
|
|
11
|
+
# row_for skips that and lets Ingest::Writer bind straight into a prepared
|
|
12
|
+
# statement.
|
|
13
|
+
module Mapper
|
|
14
|
+
PARENTS = %w[request job_attempt scheduled_task command channel_action].freeze
|
|
15
|
+
|
|
16
|
+
# SQLite TEXT and JSON columns have no schema-level limit. These caps are
|
|
17
|
+
# therefore part of the wire contract, not presentation limits: they stop
|
|
18
|
+
# one valid 32 MiB request from becoming one equally large database cell.
|
|
19
|
+
# JSON that exceeds its cap is replaced by a small valid marker object;
|
|
20
|
+
# slicing encoded JSON could leave an invalid document in SQLite.
|
|
21
|
+
TEXT_LIMITS = {
|
|
22
|
+
"deprecations.message" => 65_536,
|
|
23
|
+
"exceptions.cause" => 65_536,
|
|
24
|
+
"exceptions.context" => 65_536,
|
|
25
|
+
"exceptions.fingerprint" => 65_536,
|
|
26
|
+
"exceptions.frames" => 262_144,
|
|
27
|
+
"exceptions.locals" => 65_536,
|
|
28
|
+
"exceptions.message" => 65_536,
|
|
29
|
+
"executions.counters" => 65_536,
|
|
30
|
+
"executions.detail" => 65_536,
|
|
31
|
+
"executions.stages" => 65_536,
|
|
32
|
+
"health_samples.detail" => 65_536,
|
|
33
|
+
"llm_calls.completion" => 16_384,
|
|
34
|
+
"llm_calls.params" => 16_384,
|
|
35
|
+
"llm_calls.prompt" => 16_384,
|
|
36
|
+
"logs.context" => 65_536,
|
|
37
|
+
"logs.message" => 65_536,
|
|
38
|
+
"logs.tags" => 65_536,
|
|
39
|
+
"n_plus_ones.sql" => 16_384,
|
|
40
|
+
"outgoing_requests.response_body" => 65_536,
|
|
41
|
+
"processes.detail" => 65_536,
|
|
42
|
+
"queries.explain" => 65_536,
|
|
43
|
+
"queries.sql" => 16_384,
|
|
44
|
+
"query_shapes.sql" => 16_384,
|
|
45
|
+
"spans.payload" => 65_536,
|
|
46
|
+
"visits.only" => 65_536
|
|
47
|
+
}.freeze
|
|
48
|
+
|
|
49
|
+
JSON_TRUNCATED = JSON.generate(truncated: true).freeze
|
|
50
|
+
JSON_ARRAY_TRUNCATED = JSON.generate([ { truncated: true } ]).freeze
|
|
51
|
+
INTEGER_MAX = (2**63) - 1
|
|
52
|
+
INTEGER_MIN = -(2**63)
|
|
53
|
+
# The gem sends UUIDs, but the wire contract only promises an opaque
|
|
54
|
+
# identifier that fits the column, so any printable ASCII token is kept.
|
|
55
|
+
IDENTIFIER = /\A[\x21-\x7e]{1,36}\z/
|
|
56
|
+
GROUP_HASH = /\A[0-9a-f]{32}\z/i
|
|
57
|
+
STRUCTURES = {
|
|
58
|
+
"stages" => Hash, "counters" => Hash, "inertia" => Hash, "params" => Hash,
|
|
59
|
+
"frames" => Array, "fingerprint" => Array, "tags" => Array,
|
|
60
|
+
"only" => Array, "attributes" => Hash, "locals" => Hash
|
|
61
|
+
}.freeze
|
|
62
|
+
|
|
63
|
+
COLUMN_CACHE = {}
|
|
64
|
+
SERIALIZATION_COLUMN_CACHE = {}
|
|
65
|
+
COLUMN_CACHE_MUTEX = Mutex.new
|
|
66
|
+
TIME_PREFIX_CACHE_KEY = :railwatch_ingest_time_prefixes
|
|
67
|
+
TIME_PREFIX_CACHE_LIMIT = 64
|
|
68
|
+
private_constant :COLUMN_CACHE, :SERIALIZATION_COLUMN_CACHE, :COLUMN_CACHE_MUTEX,
|
|
69
|
+
:TIME_PREFIX_CACHE_KEY, :TIME_PREFIX_CACHE_LIMIT
|
|
70
|
+
|
|
71
|
+
module_function
|
|
72
|
+
|
|
73
|
+
# column name (String) => [type, limit, null, default], built once per
|
|
74
|
+
# class and reused for every row of that class.
|
|
75
|
+
def columns_for(klass)
|
|
76
|
+
COLUMN_CACHE[klass] ||= COLUMN_CACHE_MUTEX.synchronize do
|
|
77
|
+
COLUMN_CACHE[klass] ||= begin
|
|
78
|
+
columns = {}
|
|
79
|
+
serialization_columns = []
|
|
80
|
+
klass.columns_hash.each_value do |column|
|
|
81
|
+
metadata = [ column.type, column.limit, column.null, column.default ].freeze
|
|
82
|
+
columns[column.name] = metadata
|
|
83
|
+
serialization_columns << [ column.name.to_sym, *metadata ].freeze
|
|
84
|
+
end
|
|
85
|
+
SERIALIZATION_COLUMN_CACHE[klass] = serialization_columns.freeze
|
|
86
|
+
columns.freeze
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def serialization_columns_for(klass)
|
|
92
|
+
columns_for(klass)
|
|
93
|
+
SERIALIZATION_COLUMN_CACHE.fetch(klass)
|
|
94
|
+
end
|
|
95
|
+
private_class_method :serialization_columns_for
|
|
96
|
+
|
|
97
|
+
# trusted: this record was produced by the gem in this process (an
|
|
98
|
+
# embedded batch), not parsed from an HTTP body. Validation and the
|
|
99
|
+
# control-character/encoding scrub are the trust boundary and are
|
|
100
|
+
# skipped; every limit and cap still applies, since those are a storage
|
|
101
|
+
# contract rather than a question of who sent the record.
|
|
102
|
+
def row_for(rec, truncations: nil, validate_identifiers: true, trusted: false)
|
|
103
|
+
validate_record!(rec, identifiers: validate_identifiers) unless trusted
|
|
104
|
+
type = rec["t"].to_s
|
|
105
|
+
pair = PARENTS.include?(type) ? execution(rec) : mapped(type, rec)
|
|
106
|
+
return nil if pair.nil?
|
|
107
|
+
klass, attrs = pair
|
|
108
|
+
[ klass, serialize(klass, attrs, truncations: truncations, trusted: trusted) ]
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def validate_record!(rec, identifiers: true)
|
|
112
|
+
raise TypeError, "record must be an object" unless rec.is_a?(Hash)
|
|
113
|
+
raise TypeError, "t must be a string" unless rec["t"].is_a?(String)
|
|
114
|
+
|
|
115
|
+
if identifiers
|
|
116
|
+
validate_identifier!(rec, "_group", GROUP_HASH, "32 hexadecimal characters")
|
|
117
|
+
validate_identifier!(rec, "execution_id", IDENTIFIER, "a printable token of at most 36 characters")
|
|
118
|
+
validate_identifier!(rec, "trace_id", IDENTIFIER, "a printable token of at most 36 characters")
|
|
119
|
+
end
|
|
120
|
+
STRUCTURES.each do |field, expected|
|
|
121
|
+
value = rec[field]
|
|
122
|
+
next if value.nil? || value.is_a?(expected)
|
|
123
|
+
|
|
124
|
+
raise TypeError, "#{field} must be a #{expected.name.downcase}"
|
|
125
|
+
end
|
|
126
|
+
validate_numeric_hash!(rec["stages"], "stages")
|
|
127
|
+
validate_numeric_hash!(rec["counters"], "counters")
|
|
128
|
+
validate_frames!(rec["frames"])
|
|
129
|
+
validate_locals!(rec["locals"])
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def validate_identifier!(rec, field, pattern, description)
|
|
133
|
+
value = rec[field]
|
|
134
|
+
return if value.nil?
|
|
135
|
+
raise TypeError, "#{field} must be #{description}" unless value.is_a?(String) && value.match?(pattern)
|
|
136
|
+
end
|
|
137
|
+
private_class_method :validate_identifier!
|
|
138
|
+
|
|
139
|
+
def validate_numeric_hash!(value, field)
|
|
140
|
+
return unless value
|
|
141
|
+
return if value.all? { |key, number| key.is_a?(String) && number.is_a?(Numeric) && number.to_f.finite? && number >= 0 }
|
|
142
|
+
|
|
143
|
+
raise TypeError, "#{field} must contain non-negative numbers"
|
|
144
|
+
end
|
|
145
|
+
private_class_method :validate_numeric_hash!
|
|
146
|
+
|
|
147
|
+
def validate_frames!(frames)
|
|
148
|
+
return unless frames
|
|
149
|
+
valid = frames.all? do |frame|
|
|
150
|
+
frame.is_a?(Hash) && %w[file function].all? { |field| frame[field].nil? || frame[field].is_a?(String) } &&
|
|
151
|
+
(frame["line"].nil? || (frame["line"].is_a?(Integer) && frame["line"] >= 0)) &&
|
|
152
|
+
(frame["in_app"].nil? || frame["in_app"] == true || frame["in_app"] == false) &&
|
|
153
|
+
(frame["code"].nil? || (frame["code"].is_a?(Hash) &&
|
|
154
|
+
frame["code"].all? { |line, source| line.is_a?(String) && source.is_a?(String) }))
|
|
155
|
+
end
|
|
156
|
+
raise TypeError, "frames must contain frame objects" unless valid
|
|
157
|
+
end
|
|
158
|
+
private_class_method :validate_frames!
|
|
159
|
+
|
|
160
|
+
def validate_locals!(locals)
|
|
161
|
+
return unless locals
|
|
162
|
+
return if locals.all? { |key, value| key.is_a?(String) && !value.is_a?(Hash) && !value.is_a?(Array) }
|
|
163
|
+
|
|
164
|
+
raise TypeError, "locals must contain scalar values"
|
|
165
|
+
end
|
|
166
|
+
private_class_method :validate_locals!
|
|
167
|
+
|
|
168
|
+
# AR instance for callers that want validations, callbacks, or a normal
|
|
169
|
+
# object (specs, anything that isn't the hot ingest path). row_for's
|
|
170
|
+
# JSON columns come back pre-encoded as strings; the AR json type does
|
|
171
|
+
# not parse a String on assignment, so they're inflated here.
|
|
172
|
+
def build(rec)
|
|
173
|
+
pair = row_for(rec)
|
|
174
|
+
return nil if pair.nil?
|
|
175
|
+
klass, attrs = pair
|
|
176
|
+
cols = columns_for(klass)
|
|
177
|
+
ar_attrs = attrs.each_with_object({}) do |(name, value), out|
|
|
178
|
+
type, = cols[name.to_s]
|
|
179
|
+
out[name] = type == :json && value.is_a?(String) ? JSON.parse(value) : value
|
|
180
|
+
end
|
|
181
|
+
klass.new(ar_attrs)
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# Person writes use a purpose-built bulk upsert instead of row_for, but
|
|
185
|
+
# the same trust-boundary rules still apply to their wire strings.
|
|
186
|
+
def person_record(rec, validate_identifiers: true)
|
|
187
|
+
validate_record!(rec, identifiers: validate_identifiers)
|
|
188
|
+
{
|
|
189
|
+
"id" => cast_value(rec["id"], :string, 255, klass: Telemetry::Person, name: :ref),
|
|
190
|
+
"name" => cast_value(rec["name"], :string, 255, klass: Telemetry::Person, name: :name),
|
|
191
|
+
"email" => cast_value(rec["email"], :string, 255, klass: Telemetry::Person, name: :email),
|
|
192
|
+
"tenant" => cast_value(rec["tenant"], :string, 255, klass: Telemetry::Person, name: :app_tenant),
|
|
193
|
+
"timestamp" => rec["timestamp"]
|
|
194
|
+
}
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def mapped(type, rec)
|
|
198
|
+
case type
|
|
199
|
+
when "query" then child(Telemetry::Query, rec, %w[sql name duration connection adapter role async in_transaction row_count source allocations explain])
|
|
200
|
+
when "span" then span(rec)
|
|
201
|
+
when "health" then health(rec)
|
|
202
|
+
when "exception" then exception(rec)
|
|
203
|
+
when "cache_event" then child(Telemetry::CacheEvent, rec, %w[store key type duration ttl hits])
|
|
204
|
+
when "mail" then child(Telemetry::Mail, rec, %w[mailer subject to cc bcc attachments delivery_method perform_deliveries duration failed message_id])
|
|
205
|
+
when "broadcast" then child(Telemetry::Broadcast, rec, %w[kind stream channel action bytes duration failed])
|
|
206
|
+
when "notification" then child(Telemetry::Notification, rec, %w[notifier delivery_method channel duration failed])
|
|
207
|
+
when "outgoing_request" then child(Telemetry::OutgoingRequest, rec, %w[host method url duration status_code request_size response_size error source response_body])
|
|
208
|
+
when "llm_call" then child(Telemetry::LlmCall, rec, %w[operation provider model response_model tool_name duration status error
|
|
209
|
+
streaming message_count tool_count input_tokens output_tokens cache_read_tokens cache_write_tokens thinking_tokens
|
|
210
|
+
cost_nanos workflow_id workflow_name workflow_step_id workflow_step_name workflow_step_parent_id prompt completion
|
|
211
|
+
finish_reason provider_request_id tools cost_reported attachments attachment_types attachment_names tool_call_id params])
|
|
212
|
+
when "profile" then profile(rec)
|
|
213
|
+
when "attachment" then attachment(rec)
|
|
214
|
+
when "storage_op" then child(Telemetry::StorageOp, rec, %w[service op key duration])
|
|
215
|
+
when "view_render" then child(Telemetry::ViewRender, rec, %w[identifier kind layout count cache_hits duration])
|
|
216
|
+
when "log" then child(Telemetry::Log, rec, %w[level message tags context source])
|
|
217
|
+
when "enqueued_job" then enqueued_job(rec)
|
|
218
|
+
when "transaction" then child(Telemetry::Transaction, rec, %w[duration outcome connection statement_count])
|
|
219
|
+
when "n_plus_one" then child(Telemetry::NPlusOne, rec, %w[sql count source])
|
|
220
|
+
when "deprecation" then child(Telemetry::Deprecation, rec, %w[message gem_name horizon source])
|
|
221
|
+
when "visit" then child(Telemetry::Visit, rec, %w[component url method duration status partial only props_bytes user_agent lcp cls inp ttfb])
|
|
222
|
+
when "session" then session(rec)
|
|
223
|
+
when "user" then [ Telemetry::Person, { ref: rec["id"].to_s } ] # replaced by touch_from_record; never saved as-is
|
|
224
|
+
when "process" then process(rec)
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def envelope(rec)
|
|
229
|
+
{
|
|
230
|
+
occurred_at: timestamp_string(rec["timestamp"]),
|
|
231
|
+
deploy: rec["deploy"],
|
|
232
|
+
server: rec["server"],
|
|
233
|
+
group_hash: rec["_group"],
|
|
234
|
+
trace_id: rec["trace_id"],
|
|
235
|
+
execution_source: rec["execution_source"],
|
|
236
|
+
execution_id: rec["execution_id"],
|
|
237
|
+
execution_preview: rec["execution_preview"],
|
|
238
|
+
execution_stage: rec["execution_stage"],
|
|
239
|
+
user_ref: rec["user"],
|
|
240
|
+
app_tenant: rec["tenant"]
|
|
241
|
+
}
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def child(klass, rec, keys)
|
|
245
|
+
attrs = envelope(rec)
|
|
246
|
+
keys.each { |k| attrs[k.to_sym] = rec[k] }
|
|
247
|
+
[ klass, attrs ]
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def execution(rec)
|
|
251
|
+
kind = rec["t"]
|
|
252
|
+
name = case kind
|
|
253
|
+
when "request" then "#{rec['method']} #{rec['route']}"
|
|
254
|
+
when "command" then rec["command"] || rec["name"]
|
|
255
|
+
when "scheduled_task" then rec["task_key"] || rec["name"]
|
|
256
|
+
when "channel_action" then "#{rec['channel']}##{rec['action']}"
|
|
257
|
+
else rec["name"]
|
|
258
|
+
end
|
|
259
|
+
detail_keys = case kind
|
|
260
|
+
when "request" then %w[url path ip headers payload context user_agent view_runtime db_runtime redirect_to halted_callback unpermitted_parameters rate_limited format inertia request_size response_size gc_time]
|
|
261
|
+
when "command" then %w[context gc_time interactive]
|
|
262
|
+
when "channel_action" then %w[channel context gc_time]
|
|
263
|
+
else %w[job_id provider_job_id attempt_id adapter priority db_runtime arguments_preview context gc_time schedule]
|
|
264
|
+
end
|
|
265
|
+
attrs = envelope(rec)
|
|
266
|
+
attrs[:kind] = kind
|
|
267
|
+
attrs[:name] = name.to_s
|
|
268
|
+
attrs[:duration] = rec["duration"]
|
|
269
|
+
attrs[:status] = rec["status_code"] || rec["exit_code"]
|
|
270
|
+
attrs[:outcome] = rec["status"]
|
|
271
|
+
attrs[:method] = rec["method"]
|
|
272
|
+
attrs[:route] = rec["route"]
|
|
273
|
+
attrs[:controller] = rec["controller"]
|
|
274
|
+
attrs[:action] = rec["action"]
|
|
275
|
+
attrs[:queue] = rec["queue"]
|
|
276
|
+
attrs[:attempt] = rec["attempt"]
|
|
277
|
+
attrs[:queue_latency] = rec["queue_latency"]
|
|
278
|
+
attrs[:queue_time] = rec["queue_time"]
|
|
279
|
+
attrs[:parent_id] = rec["parent_id"]
|
|
280
|
+
attrs[:job_id] = rec["job_id"]
|
|
281
|
+
attrs[:task_key] = rec["task_key"]
|
|
282
|
+
attrs[:inertia_component] = rec.dig("inertia", "component")
|
|
283
|
+
attrs[:inertia_partial] = rec.dig("inertia", "partial_only").present?
|
|
284
|
+
attrs[:allocations] = rec["allocations"]
|
|
285
|
+
attrs[:peak_memory] = rec["peak_memory"]
|
|
286
|
+
attrs[:exception_preview] = rec["exception_preview"]
|
|
287
|
+
attrs[:stages] = rec["stages"] || {}
|
|
288
|
+
attrs[:counters] = rec["counters"] || {}
|
|
289
|
+
attrs[:detail] = rec.slice(*detail_keys)
|
|
290
|
+
[ Telemetry::Execution, attrs ]
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
def exception(rec)
|
|
294
|
+
attrs = envelope(rec)
|
|
295
|
+
attrs[:class_name] = rec["class"].to_s
|
|
296
|
+
attrs[:message] = rec["message"].to_s
|
|
297
|
+
attrs[:handled] = rec["handled"] ? true : false
|
|
298
|
+
attrs[:severity] = rec["severity"]
|
|
299
|
+
attrs[:source] = rec["source"]
|
|
300
|
+
attrs[:file] = rec["file"]
|
|
301
|
+
attrs[:line] = rec["line"]
|
|
302
|
+
attrs[:frames] = rec["frames"] || []
|
|
303
|
+
attrs[:cause] = rec["cause"]
|
|
304
|
+
attrs[:locals] = rec["locals"].is_a?(Hash) ? rec["locals"] : nil
|
|
305
|
+
attrs[:context] = rec["context"]
|
|
306
|
+
attrs[:fingerprint] = rec["fingerprint"] || []
|
|
307
|
+
attrs[:fingerprint_source] = rec["fingerprint_source"]
|
|
308
|
+
attrs[:ruby_version] = rec["ruby_version"]
|
|
309
|
+
attrs[:rails_version] = rec["rails_version"]
|
|
310
|
+
[ Telemetry::Exception, attrs ]
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
def enqueued_job(rec)
|
|
314
|
+
attrs = envelope(rec)
|
|
315
|
+
attrs[:job_id] = rec["job_id"]
|
|
316
|
+
attrs[:name] = rec["name"].to_s
|
|
317
|
+
attrs[:queue] = rec["queue"]
|
|
318
|
+
attrs[:adapter] = rec["adapter"]
|
|
319
|
+
attrs[:priority] = rec["priority"]
|
|
320
|
+
attrs[:scheduled_at] = rec["scheduled_at"] && timestamp_string(rec["scheduled_at"])
|
|
321
|
+
attrs[:duration] = rec["duration"]
|
|
322
|
+
attrs[:failed] = rec["failed"] ? true : false
|
|
323
|
+
[ Telemetry::EnqueuedJob, attrs ]
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
# The gem ships collapsed stacks base64(gzip); stored as the gzip bytes.
|
|
327
|
+
def profile(rec)
|
|
328
|
+
attrs = envelope(rec)
|
|
329
|
+
attrs[:profiler] = rec["profiler"]
|
|
330
|
+
attrs[:mode] = rec["mode"]
|
|
331
|
+
attrs[:interval] = rec["interval"]
|
|
332
|
+
attrs[:duration] = rec["duration"]
|
|
333
|
+
attrs[:samples] = rec["samples"]
|
|
334
|
+
attrs[:stacks_bytes] = declared_bytes(rec["stacks_bytes"], "stacks_bytes",
|
|
335
|
+
max_bytes: Telemetry::Profile::MAX_INGEST_BYTES)
|
|
336
|
+
attrs[:stacks] = compressed_blob(rec["stacks"], "stacks")
|
|
337
|
+
Telemetry::BoundedGzip.verify!(attrs[:stacks], max_bytes: Telemetry::Profile::MAX_INGEST_BYTES,
|
|
338
|
+
expected_bytes: attrs[:stacks_bytes], utf8: true)
|
|
339
|
+
[ Telemetry::Profile, attrs ]
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
def attachment(rec)
|
|
343
|
+
attrs = envelope(rec)
|
|
344
|
+
attrs[:name] = rec["name"]
|
|
345
|
+
attrs[:content_type] = rec["content_type"]
|
|
346
|
+
attrs[:bytes] = declared_bytes(rec["bytes"], "bytes", max_bytes: Telemetry::Attachment::MAX_BODY_BYTES)
|
|
347
|
+
attrs[:data] = compressed_blob(rec["data"], "data")
|
|
348
|
+
Telemetry::BoundedGzip.verify!(attrs[:data], max_bytes: Telemetry::Attachment::MAX_BODY_BYTES,
|
|
349
|
+
expected_bytes: attrs[:bytes])
|
|
350
|
+
attrs[:exception_group_hash] = rec["exception_group_hash"]
|
|
351
|
+
attrs[:truncated] = rec["truncated"] ? true : false
|
|
352
|
+
[ Telemetry::Attachment, attrs ]
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
def compressed_blob(value, field)
|
|
356
|
+
Base64.strict_decode64(value.to_s)
|
|
357
|
+
rescue ArgumentError
|
|
358
|
+
raise ArgumentError, "#{field} is not valid base64"
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
def declared_bytes(value, field, max_bytes:)
|
|
362
|
+
bytes = if value.is_a?(Integer)
|
|
363
|
+
value
|
|
364
|
+
elsif value.is_a?(String)
|
|
365
|
+
# Check the O(1) byte length before scanning or converting. Without
|
|
366
|
+
# this guard an otherwise-valid request can spend seconds turning a
|
|
367
|
+
# multi-megabyte decimal string into a huge Integer merely to reject it.
|
|
368
|
+
max_digits = max_bytes.to_s.bytesize
|
|
369
|
+
value.to_i if value.bytesize <= max_digits && value.match?(/\A\d+\z/)
|
|
370
|
+
end
|
|
371
|
+
return bytes if bytes&.between?(0, max_bytes)
|
|
372
|
+
|
|
373
|
+
raise ArgumentError, "#{field} must be an integer between 0 and #{max_bytes}"
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
# `id` is the gem's session key; `session_id` here, since the row has its
|
|
377
|
+
# own primary key.
|
|
378
|
+
def session(rec)
|
|
379
|
+
attrs = envelope(rec)
|
|
380
|
+
attrs[:session_id] = rec["id"].to_s
|
|
381
|
+
attrs[:source] = rec["source"]
|
|
382
|
+
attrs[:status] = rec["status"]
|
|
383
|
+
attrs[:started_at] = rec["started_at"] && timestamp_string(rec["started_at"])
|
|
384
|
+
attrs[:duration] = rec["duration"]
|
|
385
|
+
attrs[:requests] = rec["requests"]
|
|
386
|
+
attrs[:visits] = rec["visits"]
|
|
387
|
+
attrs[:error_count] = rec["errors"]
|
|
388
|
+
attrs[:ended] = rec["ended"] ? true : false
|
|
389
|
+
[ Telemetry::Session, attrs ]
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
def span(rec)
|
|
393
|
+
attrs = envelope(rec)
|
|
394
|
+
attrs[:name] = rec["name"]
|
|
395
|
+
attrs[:duration] = rec["duration"]
|
|
396
|
+
attrs[:payload] = rec["attributes"]
|
|
397
|
+
attrs[:status] = rec["status"]
|
|
398
|
+
[ Telemetry::Span, attrs ]
|
|
399
|
+
end
|
|
400
|
+
|
|
401
|
+
def health(rec)
|
|
402
|
+
[ Telemetry::HealthSample, {
|
|
403
|
+
sampled_at: timestamp_string(rec["timestamp"]), pid: rec["pid"], role: rec["role"], server: rec["server"], deploy: rec["deploy"],
|
|
404
|
+
threads_max: rec["threads_max"], threads_busy: rec["threads_busy"], backlog: rec["backlog"],
|
|
405
|
+
pool_size: rec["pool_size"], pool_busy: rec["pool_busy"], pool_waiting: rec["pool_waiting"],
|
|
406
|
+
queue_depth: rec["queue_depth"], queue_latency: rec["queue_latency"], memory: rec["memory"],
|
|
407
|
+
# The gem packs queues, workers, requests_count, running,
|
|
408
|
+
# max_threads_reached, and recurring_tasks into one JSON string
|
|
409
|
+
# under `detail` (lib/railwatch/health.rb). Slicing those names off
|
|
410
|
+
# the top of the record found nothing, so every health sample landed
|
|
411
|
+
# with an empty detail and the queue-depth panel stayed blank.
|
|
412
|
+
detail: rec["detail"] || {}
|
|
413
|
+
} ]
|
|
414
|
+
end
|
|
415
|
+
|
|
416
|
+
def process(rec)
|
|
417
|
+
[ Telemetry::Process, {
|
|
418
|
+
booted_at: timestamp_string(rec["timestamp"]), pid: rec["pid"], role: rec["role"], server: rec["server"],
|
|
419
|
+
deploy: rec["deploy"], ruby_version: rec["ruby_version"], rails_version: rec["rails_version"],
|
|
420
|
+
# Gems older than 0.1.3 send lantern_version; processes recorded
|
|
421
|
+
# by one are still readable.
|
|
422
|
+
railwatch_version: rec["railwatch_version"] || rec["lantern_version"], boot_seconds: rec["boot_seconds"],
|
|
423
|
+
detail: rec.slice("app", "environment", "database_adapter", "queue_adapter", "cache_store")
|
|
424
|
+
} ]
|
|
425
|
+
end
|
|
426
|
+
|
|
427
|
+
# Applies the casts AR would apply on save, but that raw SQL bypasses:
|
|
428
|
+
# JSON-encode hashes/arrays, format times as UTC strings, booleans as
|
|
429
|
+
# 0/1, coerce numeric columns, truncate strings to their column limit,
|
|
430
|
+
# and substitute the column's default when a NOT NULL column (frames,
|
|
431
|
+
# tags, only, ...) was left out of the wire record - the raw insert
|
|
432
|
+
# skips SQLite's own DEFAULT clause because it names every column
|
|
433
|
+
# explicitly, so a bare nil would otherwise hit a NOT NULL violation
|
|
434
|
+
# that AR's insert would have silently defaulted around.
|
|
435
|
+
def serialize(klass, attrs, truncations: nil, trusted: false)
|
|
436
|
+
out = {}
|
|
437
|
+
serialization_columns_for(klass).each do |name, type, limit, null, default|
|
|
438
|
+
next unless attrs.key?(name)
|
|
439
|
+
value = attrs[name]
|
|
440
|
+
value = default if value.nil? && !null && !default.nil?
|
|
441
|
+
out[name] = cast_value(value, type, limit, klass: klass, name: name, truncations: truncations, trusted: trusted)
|
|
442
|
+
end
|
|
443
|
+
return out if out.size == attrs.size
|
|
444
|
+
|
|
445
|
+
# Mapper attribute sets are fixed and take the allocation-free path
|
|
446
|
+
# above. Preserve serialize's behavior for callers with an unexpected
|
|
447
|
+
# key, including the useful KeyError identifying a missing column.
|
|
448
|
+
columns = columns_for(klass)
|
|
449
|
+
attrs.each do |name, value|
|
|
450
|
+
type, limit, null, default = columns.fetch(name.to_s)
|
|
451
|
+
value = default if value.nil? && !null && !default.nil?
|
|
452
|
+
out[name] = cast_value(value, type, limit, klass: klass, name: name, truncations: truncations, trusted: trusted)
|
|
453
|
+
end
|
|
454
|
+
out
|
|
455
|
+
end
|
|
456
|
+
|
|
457
|
+
# Wire timestamps are rounded to integer microseconds, then only one
|
|
458
|
+
# Time per distinct second supplies the UTC calendar prefix. This avoids
|
|
459
|
+
# the Rational/divmod work MRI repeats for every calendar field on a Time
|
|
460
|
+
# constructed from a Float while preserving SQLite's six-digit format.
|
|
461
|
+
def timestamp_string(value)
|
|
462
|
+
microseconds = (Float(value) * 1_000_000).round
|
|
463
|
+
seconds = microseconds / 1_000_000
|
|
464
|
+
usec = microseconds - (seconds * 1_000_000)
|
|
465
|
+
cache = Thread.current[TIME_PREFIX_CACHE_KEY] ||= {}
|
|
466
|
+
prefix = cache[seconds]
|
|
467
|
+
unless prefix
|
|
468
|
+
cache.clear if cache.size >= TIME_PREFIX_CACHE_LIMIT
|
|
469
|
+
time = Time.at(seconds).utc
|
|
470
|
+
prefix = format("%04d-%02d-%02d %02d:%02d:%02d.", time.year, time.month, time.day, time.hour, time.min, time.sec).freeze
|
|
471
|
+
cache[seconds] = prefix
|
|
472
|
+
end
|
|
473
|
+
prefix + format("%06d", usec)
|
|
474
|
+
end
|
|
475
|
+
|
|
476
|
+
# Kept for callers that pass Time values directly to cast_value; wire
|
|
477
|
+
# timestamps already arrive here as their final SQLite string.
|
|
478
|
+
def format_time(value)
|
|
479
|
+
t = value.utc
|
|
480
|
+
format("%04d-%02d-%02d %02d:%02d:%02d.%06d", t.year, t.month, t.day, t.hour, t.min, t.sec, t.usec)
|
|
481
|
+
end
|
|
482
|
+
|
|
483
|
+
def cast_value(value, type, limit, klass: nil, name: nil, truncations: nil, trusted: false)
|
|
484
|
+
return nil if value.nil?
|
|
485
|
+
if type == :json
|
|
486
|
+
# Health detail is already JSON-encoded on the wire. Decode it before
|
|
487
|
+
# sanitising so the database stores an object, not a JSON string that
|
|
488
|
+
# merely contains another document.
|
|
489
|
+
value = JSON.parse(trusted ? value : sanitize_string(value)) if value.is_a?(String)
|
|
490
|
+
sanitized = trusted ? value : sanitize_json(value)
|
|
491
|
+
encoded = JSON.generate(sanitized)
|
|
492
|
+
replacement = sanitized.is_a?(Array) ? JSON_ARRAY_TRUNCATED : JSON_TRUNCATED
|
|
493
|
+
return cap_text(encoded, text_limit(klass, name), klass, name, truncations, replacement: replacement)
|
|
494
|
+
end
|
|
495
|
+
if type == :datetime
|
|
496
|
+
return value if value.is_a?(String)
|
|
497
|
+
return format_time(value) if value.is_a?(Time) || value.is_a?(ActiveSupport::TimeWithZone)
|
|
498
|
+
end
|
|
499
|
+
if type == :boolean
|
|
500
|
+
raise TypeError, "expected boolean" unless value == true || value == false
|
|
501
|
+
|
|
502
|
+
return value ? 1 : 0
|
|
503
|
+
end
|
|
504
|
+
|
|
505
|
+
case value
|
|
506
|
+
when Hash, Array
|
|
507
|
+
raise TypeError, "unexpected #{value.class} for #{type.inspect} column"
|
|
508
|
+
else
|
|
509
|
+
case type
|
|
510
|
+
when :string
|
|
511
|
+
cap_text(trusted ? value.to_s : sanitize_string(value.to_s), limit, klass, name, truncations)
|
|
512
|
+
when :text
|
|
513
|
+
cap_text(trusted ? value.to_s : sanitize_string(value.to_s), text_limit(klass, name), klass, name, truncations)
|
|
514
|
+
when :integer, :bigint
|
|
515
|
+
# Signed: queue_latency and drift are clock differences between an
|
|
516
|
+
# enqueuing host and a worker, and skew makes them negative.
|
|
517
|
+
raise TypeError, "expected integer" unless value.is_a?(Integer)
|
|
518
|
+
raise RangeError, "integer out of range" unless value.between?(INTEGER_MIN, INTEGER_MAX)
|
|
519
|
+
value
|
|
520
|
+
when :float
|
|
521
|
+
raise TypeError, "expected number" unless value.is_a?(Numeric)
|
|
522
|
+
number = value.to_f
|
|
523
|
+
raise RangeError, "number out of range" unless number.finite?
|
|
524
|
+
number
|
|
525
|
+
else value
|
|
526
|
+
end
|
|
527
|
+
end
|
|
528
|
+
end
|
|
529
|
+
|
|
530
|
+
def text_limit(klass, name)
|
|
531
|
+
return nil unless klass && name
|
|
532
|
+
TEXT_LIMITS.fetch("#{klass.table_name}.#{name}")
|
|
533
|
+
end
|
|
534
|
+
private_class_method :text_limit
|
|
535
|
+
|
|
536
|
+
def cap_text(value, limit, klass, name, truncations, replacement: nil)
|
|
537
|
+
return value unless limit && value.bytesize > limit
|
|
538
|
+
|
|
539
|
+
truncations << "#{klass.table_name}.#{name}" if truncations
|
|
540
|
+
replacement || value.byteslice(0, limit).scrub("")
|
|
541
|
+
end
|
|
542
|
+
private_class_method :cap_text
|
|
543
|
+
|
|
544
|
+
def sanitize_json(value)
|
|
545
|
+
case value
|
|
546
|
+
when Hash
|
|
547
|
+
value.each_with_object({}) do |(key, child), out|
|
|
548
|
+
out[sanitize_string(key.to_s)] = sanitize_json(child)
|
|
549
|
+
end
|
|
550
|
+
when Array
|
|
551
|
+
value.map { |child| sanitize_json(child) }
|
|
552
|
+
when String
|
|
553
|
+
sanitize_string(value)
|
|
554
|
+
else
|
|
555
|
+
value
|
|
556
|
+
end
|
|
557
|
+
end
|
|
558
|
+
private_class_method :sanitize_json
|
|
559
|
+
|
|
560
|
+
CONTROL_RANGE = "\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F"
|
|
561
|
+
CONTROL_CHARS = /[#{CONTROL_RANGE}]/
|
|
562
|
+
|
|
563
|
+
# scrub + delete allocate two strings per value, and this runs on every
|
|
564
|
+
# string column of every record -- half the mapper's time on a real
|
|
565
|
+
# batch, in which not one string needed cleaning. Check first; only
|
|
566
|
+
# copy when there is something to remove.
|
|
567
|
+
def sanitize_string(value)
|
|
568
|
+
return value if value.valid_encoding? && !value.match?(CONTROL_CHARS)
|
|
569
|
+
|
|
570
|
+
value.scrub.delete(CONTROL_RANGE)
|
|
571
|
+
end
|
|
572
|
+
private_class_method :sanitize_string
|
|
573
|
+
end
|
|
574
|
+
end
|
|
575
|
+
end
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Railwatch
|
|
4
|
+
require "zlib"
|
|
5
|
+
require "stringio"
|
|
6
|
+
|
|
7
|
+
module Ingest
|
|
8
|
+
# Decodes the gem's wire format: optionally gzipped NDJSON.
|
|
9
|
+
module Payload
|
|
10
|
+
class Malformed < StandardError; end
|
|
11
|
+
MAX_BYTES = IngestRequestBodyLimit::MAX_BYTES
|
|
12
|
+
MAX_RECORDS = 20_000
|
|
13
|
+
READ_SIZE = 64.kilobytes
|
|
14
|
+
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
def parse(request)
|
|
18
|
+
declared_bytes = request.content_length
|
|
19
|
+
raise Malformed, "body too large" if declared_bytes && declared_bytes > MAX_BYTES
|
|
20
|
+
|
|
21
|
+
raw = read_bounded(request.body)
|
|
22
|
+
raise Malformed, "empty body" if raw.blank?
|
|
23
|
+
|
|
24
|
+
records = []
|
|
25
|
+
pending = String.new(encoding: Encoding::BINARY)
|
|
26
|
+
decoded_bytes = 0
|
|
27
|
+
each_decoded_chunk(raw, gzip: request.headers["Content-Encoding"].to_s.include?("gzip")) do |chunk|
|
|
28
|
+
decoded_bytes += chunk.bytesize
|
|
29
|
+
raise Malformed, "payload too large" if decoded_bytes > MAX_BYTES
|
|
30
|
+
|
|
31
|
+
pending << chunk
|
|
32
|
+
pending = consume_complete_lines(pending, records)
|
|
33
|
+
end
|
|
34
|
+
append_record(pending, records)
|
|
35
|
+
records
|
|
36
|
+
rescue Zlib::Error => e
|
|
37
|
+
raise Malformed, "bad gzip: #{e.message}"
|
|
38
|
+
rescue JSON::ParserError => e
|
|
39
|
+
raise Malformed, "bad json: #{e.message}"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def read_bounded(io)
|
|
43
|
+
body = String.new(encoding: Encoding::BINARY)
|
|
44
|
+
loop do
|
|
45
|
+
chunk = io.read([ READ_SIZE, (MAX_BYTES + 1) - body.bytesize ].min)
|
|
46
|
+
break if chunk.nil? || chunk.empty?
|
|
47
|
+
|
|
48
|
+
body << chunk
|
|
49
|
+
raise Malformed, "body too large" if body.bytesize > MAX_BYTES
|
|
50
|
+
end
|
|
51
|
+
body
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def each_decoded_chunk(raw, gzip:)
|
|
55
|
+
return each_plain_chunk(raw) { |chunk| yield chunk } unless gzip
|
|
56
|
+
|
|
57
|
+
reader = Zlib::GzipReader.new(StringIO.new(raw))
|
|
58
|
+
remaining = MAX_BYTES + 1
|
|
59
|
+
loop do
|
|
60
|
+
chunk = reader.readpartial([ READ_SIZE, remaining ].min)
|
|
61
|
+
remaining -= chunk.bytesize
|
|
62
|
+
yield chunk
|
|
63
|
+
end
|
|
64
|
+
rescue EOFError
|
|
65
|
+
nil
|
|
66
|
+
ensure
|
|
67
|
+
reader&.close
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def each_plain_chunk(raw)
|
|
71
|
+
offset = 0
|
|
72
|
+
while offset < raw.bytesize
|
|
73
|
+
yield raw.byteslice(offset, READ_SIZE)
|
|
74
|
+
offset += READ_SIZE
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def consume_complete_lines(buffer, records)
|
|
79
|
+
offset = 0
|
|
80
|
+
while (newline = buffer.index("\n", offset))
|
|
81
|
+
append_record(buffer.byteslice(offset, newline - offset), records)
|
|
82
|
+
offset = newline + 1
|
|
83
|
+
end
|
|
84
|
+
offset.zero? ? buffer : buffer.byteslice(offset..)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def append_record(line, records)
|
|
88
|
+
line = line.strip
|
|
89
|
+
return if line.empty?
|
|
90
|
+
raise Malformed, "too many records" if records.size >= MAX_RECORDS
|
|
91
|
+
|
|
92
|
+
records << JSON.parse(line)
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|