railwatch 0.3.4 → 0.3.6
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 +41 -2
- data/app/channels/railwatch/environment_channel.rb +17 -4
- data/app/jobs/railwatch/prune_telemetry_job.rb +20 -1
- data/app/models/railwatch/ingest/batch.rb +1 -1
- data/app/models/railwatch/ingest/mapper.rb +1 -3
- data/app/models/railwatch/telemetry_record.rb +48 -0
- data/db/railwatch_telemetry_migrate/20260903000000_enable_incremental_vacuum.rb +62 -0
- data/docs/embedded.md +64 -7
- data/docs/getting-started.md +0 -2
- data/docs/source-maps.md +1 -3
- data/lib/generators/railwatch/install/install_generator.rb +0 -8
- data/lib/railwatch/backtrace.rb +12 -2
- data/lib/railwatch/configuration.rb +6 -5
- data/lib/railwatch/engine.rb +2 -2
- data/lib/railwatch/export/client.rb +10 -3
- data/lib/railwatch/reporter.rb +2 -12
- data/lib/railwatch/source_maps.rb +13 -1
- data/lib/railwatch/spec_helper.rb +1 -1
- data/lib/railwatch/transport/http.rb +14 -5
- data/lib/railwatch/version.rb +1 -1
- data/lib/tasks/railwatch_tasks.rake +161 -0
- data/public/railwatch/assets/{app-layout-yh-sPWgK.js → app-layout-B5Fcdwfz.js} +1 -1
- data/public/railwatch/assets/{app-wordmark-nbjkzxwQ.js → app-wordmark-1SRllgHv.js} +1 -1
- data/public/railwatch/assets/{appearance-CDuRvQTB.js → appearance-DbwahDrc.js} +1 -1
- data/public/railwatch/assets/application-CwshqwM6.css +1 -0
- data/public/railwatch/assets/{arrow-up-DVtOGdVA.js → arrow-up-DwKQcab4.js} +1 -1
- data/public/railwatch/assets/{auth-layout-TCwPpS1K.js → auth-layout-DXfxXAN_.js} +1 -1
- data/public/railwatch/assets/{badge-Daw4Hvr8.js → badge-CApjyWcg.js} +1 -1
- data/public/railwatch/assets/{braces-DhFbHPsz.js → braces-kGSgxWnS.js} +1 -1
- data/public/railwatch/assets/{card-BX_3HXcJ.js → card-BaJ8kNkp.js} +1 -1
- data/public/railwatch/assets/{chart-B14-N9g7.js → chart-Xnrz1Nqw.js} +1 -1
- data/public/railwatch/assets/{chart-hover-Vy52H4uD.js → chart-hover-XYPNE2CX.js} +1 -1
- data/public/railwatch/assets/{chart-panel-Cb4S_ej_.js → chart-panel-BYk68mJJ.js} +1 -1
- data/public/railwatch/assets/{checkbox-D3aRSsBj.js → checkbox-Bf41R8W6.js} +1 -1
- data/public/railwatch/assets/{code-KvW8k7Jr.js → code-a-9sh36A.js} +1 -1
- data/public/railwatch/assets/{copy-block-BKFGd11J.js → copy-block-zykZWQym.js} +1 -1
- data/public/railwatch/assets/{copy-id-Djks1fXB.js → copy-id-b6CW5ESL.js} +1 -1
- data/public/railwatch/assets/{cursor-load-more-BXV0f1D_.js → cursor-load-more-0cHzbTPT.js} +1 -1
- data/public/railwatch/assets/{data-table-iv1bdF6u.js → data-table-IvA7xvrK.js} +1 -1
- data/public/railwatch/assets/{edit-BZc_Iawe.js → edit-7lmf3k80.js} +1 -1
- data/public/railwatch/assets/{edit-__9yJlO3.js → edit-DTtRV1fO.js} +1 -1
- data/public/railwatch/assets/{edit-DMKUF8Zi.js → edit-ZKE15r-F.js} +1 -1
- data/public/railwatch/assets/{empty-state-6j_0AaQQ.js → empty-state-BFTnQ2oT.js} +1 -1
- data/public/railwatch/assets/env-layout-erSDpIXg.js +1 -0
- data/public/railwatch/assets/{execution-path-CzgBUi5e.js → execution-path-Di5uUHi-.js} +1 -1
- data/public/railwatch/assets/{filter-bar-9SU5NrzX.js → filter-bar-Btm__gQi.js} +1 -1
- data/public/railwatch/assets/{flamegraph-qcekju8V.js → flamegraph-C-mZdqa7.js} +1 -1
- data/public/railwatch/assets/{frames-Cyu7KMxZ.js → frames-KZBsjg6-.js} +1 -1
- data/public/railwatch/assets/{google-sign-in-button-DsTSfmzY.js → google-sign-in-button-DJRoD72g.js} +1 -1
- data/public/railwatch/assets/index-9LL_C3eG.js +1 -0
- data/public/railwatch/assets/index-B-bSm1jB.js +1 -0
- data/public/railwatch/assets/{index-OqI59zPb.js → index-B0VXzXYG.js} +1 -1
- data/public/railwatch/assets/index-BAkZNNEh.js +1 -0
- data/public/railwatch/assets/{index-Cmlwy1-V.js → index-BWSyQRZI.js} +1 -1
- data/public/railwatch/assets/index-Bjw4sTXU.js +1 -0
- data/public/railwatch/assets/index-Bo8hrjbs.js +1 -0
- data/public/railwatch/assets/index-BoTOVM0v.js +1 -0
- data/public/railwatch/assets/index-Bot7kHMA.js +1 -0
- data/public/railwatch/assets/index-BsrCnQqg.js +1 -0
- data/public/railwatch/assets/index-BtSlLmkg.js +1 -0
- data/public/railwatch/assets/{index-BRiq3SNR.js → index-CAzQkw-h.js} +1 -1
- data/public/railwatch/assets/index-CBJZQgHA.js +1 -0
- data/public/railwatch/assets/index-CIUl9POV.js +1 -0
- data/public/railwatch/assets/{index-DlumCsWY.js → index-CP6jrrPT.js} +2 -2
- data/public/railwatch/assets/index-CU6yhEyt.js +1 -0
- data/public/railwatch/assets/{index-1ol1-QWI.js → index-CX8Br7m9.js} +1 -1
- data/public/railwatch/assets/index-CYYmZpZu.js +1 -0
- data/public/railwatch/assets/{index-DU9F5b5d.js → index-CeqMDByC.js} +1 -1
- data/public/railwatch/assets/index-Cj3_jD1E.js +1 -0
- data/public/railwatch/assets/index-D4968-TC.js +1 -0
- data/public/railwatch/assets/index-DAEzO_vq.js +1 -0
- data/public/railwatch/assets/index-DBTxQw77.js +1 -0
- data/public/railwatch/assets/index-DCAho97K.js +1 -0
- data/public/railwatch/assets/index-DEfu_YvU.js +1 -0
- data/public/railwatch/assets/{index-CeYKnapu.js → index-DXUwhSCQ.js} +1 -1
- data/public/railwatch/assets/index-DcItJxa5.js +1 -0
- data/public/railwatch/assets/index-EGU__DOB.js +1 -0
- data/public/railwatch/assets/index-HCJmFcZu.js +1 -0
- data/public/railwatch/assets/index-P_e_cI0O.js +1 -0
- data/public/railwatch/assets/index-QyHE9WsU.js +1 -0
- data/public/railwatch/assets/index-Z1c4nFL0.js +1 -0
- data/public/railwatch/assets/index-kNhZPLVj.js +1 -0
- data/public/railwatch/assets/index-uHBVO8Lh.js +1 -0
- data/public/railwatch/assets/index-wOj5j0KC.js +1 -0
- data/public/railwatch/assets/{inertia-TViv6kNv.js → inertia-Degv_G9N.js} +2 -2
- data/public/railwatch/assets/{input-error-LxImUkxv.js → input-error-C6SqO_iU.js} +1 -1
- data/public/railwatch/assets/{json-viewer-Ar4cjPDW.js → json-viewer-DcQIxT86.js} +1 -1
- data/public/railwatch/assets/klass-WgQEfWpo.js +1 -0
- data/public/railwatch/assets/{label-COUKWqE_.js → label-B_vK19tR.js} +1 -1
- data/public/railwatch/assets/{layout-DNSLAkw_.js → layout-C8q7GUlZ.js} +1 -1
- data/public/railwatch/assets/{live-dot-ChfUtY3p.js → live-dot-DaXEbkXo.js} +1 -1
- data/public/railwatch/assets/{nav-CNnDqPlm.js → nav-BnD5XArZ.js} +1 -1
- data/public/railwatch/assets/{new-Be55nmt9.js → new-BL9OqXHe.js} +1 -1
- data/public/railwatch/assets/{new-D05SajFR.js → new-BhRnQn4w.js} +1 -1
- data/public/railwatch/assets/{new-Bi_xQiIb.js → new-BqP0NhOF.js} +1 -1
- data/public/railwatch/assets/{new-BvCT8TMg.js → new-CWP3wtwt.js} +1 -1
- data/public/railwatch/assets/{new-D4uewYC8.js → new-DIjwu2jF.js} +1 -1
- data/public/railwatch/assets/{new-84S8ZJq9.js → new-uxMnBtoY.js} +1 -1
- data/public/railwatch/assets/{onboarding-CYZi5Cqc.js → onboarding-C_Y3xTsH.js} +1 -1
- data/public/railwatch/assets/{origin-identity-6q1-CBts.js → origin-identity-CNhOnfBa.js} +1 -1
- data/public/railwatch/assets/{percentile-picker-gFZCXtdb.js → percentile-picker-BujVEhP6.js} +1 -1
- data/public/railwatch/assets/{relative-time-IOOgl5n2.js → relative-time-6CoKr1lN.js} +1 -1
- data/public/railwatch/assets/release-health-1Qzc_XhT.js +1 -0
- data/public/railwatch/assets/route-xqgYjOwJ.js +1 -0
- data/public/railwatch/assets/{segmented-h1VdDTqE.js → segmented-CVKq2Xpa.js} +1 -1
- data/public/railwatch/assets/{select-_AJsUa7X.js → select-CsmSXsJ4.js} +1 -1
- data/public/railwatch/assets/{separator-BwwTYtCF.js → separator-CrM10Oot.js} +1 -1
- data/public/railwatch/assets/{series-chart-DaFPefku.js → series-chart-D33UhdG2.js} +1 -1
- data/public/railwatch/assets/{show-C8sHalnw.js → show-8H9WAGav.js} +1 -1
- data/public/railwatch/assets/show-9jDeBNpc.js +1 -0
- data/public/railwatch/assets/{show-DACku6AD.js → show-B0nHy31L.js} +3 -3
- data/public/railwatch/assets/{show-C4Ltf5i9.js → show-B3izvRdU.js} +1 -1
- data/public/railwatch/assets/show-B7NhcWgp.js +1 -0
- data/public/railwatch/assets/show-BM-E2iYc.js +1 -0
- data/public/railwatch/assets/show-BlNR3ZOZ.js +1 -0
- data/public/railwatch/assets/{show-DgSIoRvA.js → show-CJMeX75E.js} +1 -1
- data/public/railwatch/assets/{show-CeTL4B37.js → show-CMisHNAI.js} +2 -2
- data/public/railwatch/assets/{show-JxFtB4eK.js → show-CQ4Ze-Un.js} +2 -2
- data/public/railwatch/assets/{show-BM6X2Mpo.js → show-CvNV23gH.js} +1 -1
- data/public/railwatch/assets/show-DMZlwzL_.js +1 -0
- data/public/railwatch/assets/show-DUwQlohF.js +1 -0
- data/public/railwatch/assets/{show-DIOSGcXV.js → show-DZwcZZrz.js} +2 -2
- data/public/railwatch/assets/show-ixK2r2f1.js +1 -0
- data/public/railwatch/assets/{show-DYteoYWW.js → show-rysBbvt-.js} +1 -1
- data/public/railwatch/assets/show-zxVxhUel.js +1 -0
- data/public/railwatch/assets/{sort-header-DpFzXblu.js → sort-header-CKj_7G3U.js} +1 -1
- data/public/railwatch/assets/{sparkline-cell-C3-5vFkP.js → sparkline-cell-Ckg29yVq.js} +1 -1
- data/public/railwatch/assets/stat-TxvoBiC8.js +1 -0
- data/public/railwatch/assets/{status-badge-8jVV-LA4.js → status-badge-5NCVJHjo.js} +1 -1
- data/public/railwatch/assets/{tenant-path-G-6u9A-o.js → tenant-path-B5ZRK4x3.js} +1 -1
- data/public/railwatch/assets/{text-link-DfsiaCcP.js → text-link-BPllRj0F.js} +1 -1
- data/public/railwatch/assets/{textarea-Dye72uP7.js → textarea-DtD5yZp_.js} +1 -1
- data/public/railwatch/assets/{timeline-CD7WHnbo.js → timeline-DZz5ZnHN.js} +1 -1
- data/public/railwatch/assets/{transition-B_AW8rMK.js → transition-Ds6Cakuc.js} +1 -1
- data/public/railwatch/assets/{use-clipboard-ColgLyQ2.js → use-clipboard-BLjjYH8V.js} +1 -1
- data/public/railwatch/assets/use-live-GKze1mNC.js +1 -0
- data/public/railwatch/manifest.json +1291 -1238
- metadata +117 -115
- data/public/railwatch/assets/application-DD4nJA5I.css +0 -1
- data/public/railwatch/assets/env-layout-DrT8rO6P.js +0 -1
- data/public/railwatch/assets/index-5jI4aFzC.js +0 -1
- data/public/railwatch/assets/index-9KTrVnrc.js +0 -1
- data/public/railwatch/assets/index-B0-8lcTp.js +0 -1
- data/public/railwatch/assets/index-B7jjfNfO.js +0 -1
- data/public/railwatch/assets/index-BBchRy0M.js +0 -1
- data/public/railwatch/assets/index-BgKj9xhr.js +0 -1
- data/public/railwatch/assets/index-BkTZqqOu.js +0 -1
- data/public/railwatch/assets/index-BprKx8QO.js +0 -1
- data/public/railwatch/assets/index-C3jzvPs3.js +0 -1
- data/public/railwatch/assets/index-Cbs6gGyQ.js +0 -1
- data/public/railwatch/assets/index-CdRZ6AWF.js +0 -1
- data/public/railwatch/assets/index-Cwx6058d.js +0 -1
- data/public/railwatch/assets/index-D4CSdbHv.js +0 -1
- data/public/railwatch/assets/index-DEFMSkdG.js +0 -1
- data/public/railwatch/assets/index-DFiHEBSh.js +0 -1
- data/public/railwatch/assets/index-DL4vWdWJ.js +0 -1
- data/public/railwatch/assets/index-DaXgPcGL.js +0 -1
- data/public/railwatch/assets/index-DbtaU-EE.js +0 -1
- data/public/railwatch/assets/index-DeOe83F4.js +0 -1
- data/public/railwatch/assets/index-DiucHN4B.js +0 -1
- data/public/railwatch/assets/index-DlnR_l9o.js +0 -1
- data/public/railwatch/assets/index-DmRd7aIG.js +0 -1
- data/public/railwatch/assets/index-DxSh2UpM.js +0 -1
- data/public/railwatch/assets/index-MIMGuFNt.js +0 -1
- data/public/railwatch/assets/index-P4rC7IlX.js +0 -1
- data/public/railwatch/assets/index-gpPOcFWq.js +0 -1
- data/public/railwatch/assets/index-oVkururr.js +0 -1
- data/public/railwatch/assets/index-p9puqVge.js +0 -1
- data/public/railwatch/assets/klass-CJ-J4INB.js +0 -1
- data/public/railwatch/assets/release-health-DC8oc7uw.js +0 -1
- data/public/railwatch/assets/route-Dv6LAWvT.js +0 -1
- data/public/railwatch/assets/show-B7NCgkEo.js +0 -1
- data/public/railwatch/assets/show-BKqyKjBK.js +0 -1
- data/public/railwatch/assets/show-BNw4tN5q.js +0 -1
- data/public/railwatch/assets/show-BO3bnG5h.js +0 -1
- data/public/railwatch/assets/show-BhrAVAEA.js +0 -1
- data/public/railwatch/assets/show-CpfgV1jP.js +0 -1
- data/public/railwatch/assets/show-DQp_1n-B.js +0 -1
- data/public/railwatch/assets/show-DVNz46RI.js +0 -1
- data/public/railwatch/assets/stat-s4RpOS9w.js +0 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7670085bcbae37c58def37c8e23ffee964be9277a57462869346017016c33a3c
|
|
4
|
+
data.tar.gz: fb68d72c0863778eb607cfd2bded53714651caaecffefbff20994e25422a1411
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 429bb5789a56059dce9848403622511df0f5094c2efe4a6ed8c51e5086fb11eaf56b8c762ee9ae68d1ae393a5fae7af65d628df019f01b00466644e207aa6b1d
|
|
7
|
+
data.tar.gz: 0c38280c362f3529ca2af1e93a6982d77ed15a872351072727dd1152396ea2c3f1a2e634f1132a6cac83bf0ee152f818fda15db76e2251a3f0f1a7a24db5bfb1
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,45 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.3.6 (2026-09-20)
|
|
4
|
+
|
|
5
|
+
- Harden embedded live-stream authorization and telemetry delivery. Bound
|
|
6
|
+
source-file access and HTTP response reads.
|
|
7
|
+
- Simplify prelaunch interfaces: use prompt, stdin, or environment token input;
|
|
8
|
+
pass the complete transport delivery metadata; use the current process-version
|
|
9
|
+
field. Update public install examples and remove obsolete upgrade wording.
|
|
10
|
+
|
|
11
|
+
## 0.3.5 (2026-09-20)
|
|
12
|
+
|
|
13
|
+
- Make the embedded telemetry database give disk back. `PruneTelemetryJob`
|
|
14
|
+
deleted rows past `retention_days` and nothing ever vacuumed, so the freed
|
|
15
|
+
pages went on SQLite's freelist to be reused and never returned to the
|
|
16
|
+
filesystem: the file only ever grew. Measured on a copy of a real production
|
|
17
|
+
database of the same shape (bulk deletes, continuous inserts), deleting
|
|
18
|
+
40,148 rows left the file at 21M with 5,164 pages on the freelist; a single
|
|
19
|
+
`PRAGMA incremental_vacuum` took it to 44K.
|
|
20
|
+
|
|
21
|
+
Two halves, and neither is worth anything alone. A new telemetry migration,
|
|
22
|
+
`EnableIncrementalVacuum`, puts the database into `auto_vacuum=incremental`;
|
|
23
|
+
and the nightly prune now runs `PRAGMA incremental_vacuum` after its deletes,
|
|
24
|
+
bounded to 2,000 pages a slice and 25 slices (~200MB at SQLite's 4K default)
|
|
25
|
+
so a backlog drains over successive nights instead of stalling one.
|
|
26
|
+
|
|
27
|
+
The mode cannot be declared in `config/database.yml`: Rails applies its own
|
|
28
|
+
`DEFAULT_PRAGMAS` before any you declare, and `journal_mode = wal` writes the
|
|
29
|
+
file header, so by the time `auto_vacuum` runs the database is no longer new
|
|
30
|
+
and SQLite accepts the statement and ignores it. The migration is numbered
|
|
31
|
+
below `CreateTelemetry` for the same reason -- it has to run while the file
|
|
32
|
+
still holds nothing.
|
|
33
|
+
|
|
34
|
+
- Add `bin/rails railwatch:vacuum:status` and `bin/rails railwatch:vacuum`.
|
|
35
|
+
An **existing** install cannot change mode without a full `VACUUM`, which
|
|
36
|
+
rewrites the whole file with the write lock held, so nothing does that on its
|
|
37
|
+
own: the migration leaves an existing database exactly as it found it.
|
|
38
|
+
`railwatch:vacuum:status` reports the file, its size, its mode and its
|
|
39
|
+
freelist and changes nothing; `railwatch:vacuum` says what the conversion
|
|
40
|
+
will cost and then does it. Installs created from this version on need
|
|
41
|
+
neither.
|
|
42
|
+
|
|
3
43
|
## 0.3.4 (2026-09-20)
|
|
4
44
|
|
|
5
45
|
- Fix the embedded dashboard offering its install steps to an install that is
|
|
@@ -644,8 +684,7 @@ First public release.
|
|
|
644
684
|
- The installer now prefers a hidden prompt, stdin, or `RAILWATCH_TOKEN`, never
|
|
645
685
|
prints token values, and refuses to put a token in a tracked or non-ignored
|
|
646
686
|
`.env`. `railwatch:doctor` also fails when it finds a plaintext `lt_...` token
|
|
647
|
-
in likely secret-bearing files tracked by Git.
|
|
648
|
-
remains compatible but warns about shell-history and process-list exposure.
|
|
687
|
+
in likely secret-bearing files tracked by Git.
|
|
649
688
|
|
|
650
689
|
- A forked worker profiles again. `Process._fork` now resets the profiler's
|
|
651
690
|
process-global state in the child: it used to inherit `@running` holding
|
|
@@ -2,15 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
module Railwatch
|
|
4
4
|
# Live dashboard updates: Ingest::Batch broadcasts "ingested" on
|
|
5
|
-
#
|
|
5
|
+
# railwatch:environment:<id> after each write (throttled to one per 2 s), and the
|
|
6
6
|
# page reloads its props. Under HTTP Basic the browser sends the same
|
|
7
7
|
# Authorization header on the WebSocket handshake, so the channel checks
|
|
8
8
|
# the same credentials as the pages.
|
|
9
9
|
#
|
|
10
10
|
# With Basic off, Configuration#dashboard_channel_allowed? asks whichever
|
|
11
11
|
# gate the host declared: a dashboard_user resolver can refuse this
|
|
12
|
-
# request,
|
|
13
|
-
#
|
|
12
|
+
# request, or dashboard_open explicitly allows public live updates.
|
|
13
|
+
# A named base controller alone cannot authorize a channel. That last case matters
|
|
14
14
|
# because Action Cable runs on the host's own /cable endpoint: a routes
|
|
15
15
|
# constraint around the engine's mount does not cover it and a base
|
|
16
16
|
# controller cannot reach it, so "something in front of /railwatch" is not
|
|
@@ -19,7 +19,9 @@ module Railwatch
|
|
|
19
19
|
class EnvironmentChannel < ActionCable::Channel::Base
|
|
20
20
|
def subscribed
|
|
21
21
|
if params[:id].to_i == Environment::ID && Railwatch.config.dashboard_channel_allowed?(connection_request)
|
|
22
|
-
stream_from "
|
|
22
|
+
stream_from "railwatch:environment:#{Environment::ID}", coder: ActiveSupport::JSON do |message|
|
|
23
|
+
transmit_authorized(message)
|
|
24
|
+
end
|
|
23
25
|
else
|
|
24
26
|
reject
|
|
25
27
|
end
|
|
@@ -27,6 +29,17 @@ module Railwatch
|
|
|
27
29
|
|
|
28
30
|
private
|
|
29
31
|
|
|
32
|
+
# A host's resolver can revoke an operator while their socket is open.
|
|
33
|
+
def transmit_authorized(message)
|
|
34
|
+
if Railwatch.config.dashboard_channel_allowed?(connection_request)
|
|
35
|
+
transmit message
|
|
36
|
+
else
|
|
37
|
+
stop_all_streams
|
|
38
|
+
reject
|
|
39
|
+
reject_subscription
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
30
43
|
# Built from the connection's env rather than asking it for its request:
|
|
31
44
|
# ActionCable::Connection::Base#request is private (Rails documents it for
|
|
32
45
|
# use inside a Connection subclass, not from a channel), so the obvious
|
|
@@ -20,6 +20,21 @@ module Railwatch
|
|
|
20
20
|
# writer that long hold would look like a wedge and end the process.
|
|
21
21
|
MAX_BATCHES_PER_TABLE = 40
|
|
22
22
|
|
|
23
|
+
# Reclaim, bounded the same way the deletes are. Deleting rows only moves
|
|
24
|
+
# their pages to the freelist; PRAGMA incremental_vacuum is what hands
|
|
25
|
+
# them back to the filesystem, and it is only possible at all on a
|
|
26
|
+
# database in auto_vacuum=incremental (EnableIncrementalVacuum, or
|
|
27
|
+
# `bin/rails railwatch:vacuum` for a database that predates it).
|
|
28
|
+
# Measured at 80k-190k pages/s warm, so a full run is well under a second
|
|
29
|
+
# there; the slicing is for the cold, large file, where each slice takes
|
|
30
|
+
# and releases the write lock instead of holding it throughout.
|
|
31
|
+
VACUUM_PAGES_PER_SLICE = 2_000
|
|
32
|
+
# 50k pages, ~200MB at SQLite's 4KB default. A few thousand pages a night
|
|
33
|
+
# would never keep up with a night's deletes on a busy app, and the file
|
|
34
|
+
# would go on growing with the freelist; a backlog past this one still
|
|
35
|
+
# drains over successive nightly runs.
|
|
36
|
+
VACUUM_SLICES = 25
|
|
37
|
+
|
|
23
38
|
RAW = [ Telemetry::Execution, Telemetry::Query, Telemetry::Exception, Telemetry::CacheEvent, Telemetry::Mail,
|
|
24
39
|
Telemetry::Broadcast, Telemetry::Notification, Telemetry::OutgoingRequest, Telemetry::StorageOp,
|
|
25
40
|
Telemetry::ViewRender, Telemetry::Log, Telemetry::EnqueuedJob, Telemetry::Transaction,
|
|
@@ -50,7 +65,11 @@ module Railwatch
|
|
|
50
65
|
Telemetry::IngestBatch.where(received_at: ...cutoff).delete_all
|
|
51
66
|
Telemetry::Process.where(booted_at: ...cutoff).delete_all
|
|
52
67
|
Telemetry::HealthSample.where(sampled_at: ...cutoff).delete_all
|
|
53
|
-
|
|
68
|
+
# Before the checkpoint, not after: incremental_vacuum truncates the
|
|
69
|
+
# database file, and in WAL mode that truncation only reaches the file
|
|
70
|
+
# on disk once it is checkpointed.
|
|
71
|
+
TelemetryRecord.reclaim_freelist!(slice: VACUUM_PAGES_PER_SLICE, slices: VACUUM_SLICES)
|
|
72
|
+
TelemetryRecord.connection.execute("PRAGMA wal_checkpoint(#{checkpoint})") if TelemetryRecord.sqlite?
|
|
54
73
|
end
|
|
55
74
|
end
|
|
56
75
|
|
|
@@ -340,7 +340,7 @@ module Railwatch
|
|
|
340
340
|
return if last && now - last < THROTTLE_WINDOW
|
|
341
341
|
|
|
342
342
|
LAST_BROADCAST_AT[@environment.id] = now
|
|
343
|
-
ActionCable.server.broadcast("
|
|
343
|
+
ActionCable.server.broadcast("railwatch:environment:#{@environment.id}", { event: "ingested", at: now.iso8601, counts: @counts })
|
|
344
344
|
rescue StandardError, LoadError => e
|
|
345
345
|
# A live refresh ping is not worth failing a written batch over.
|
|
346
346
|
# LoadError too: a host whose production cable adapter is redis with
|
|
@@ -417,9 +417,7 @@ module Railwatch
|
|
|
417
417
|
[ Telemetry::Process, {
|
|
418
418
|
booted_at: timestamp_string(rec["timestamp"]), pid: rec["pid"], role: rec["role"], server: rec["server"],
|
|
419
419
|
deploy: rec["deploy"], ruby_version: rec["ruby_version"], rails_version: rec["rails_version"],
|
|
420
|
-
|
|
421
|
-
# by one are still readable.
|
|
422
|
-
railwatch_version: rec["railwatch_version"] || rec["lantern_version"], boot_seconds: rec["boot_seconds"],
|
|
420
|
+
railwatch_version: rec["railwatch_version"], boot_seconds: rec["boot_seconds"],
|
|
423
421
|
detail: rec.slice("app", "environment", "database_adapter", "queue_adapter", "cache_store")
|
|
424
422
|
} ]
|
|
425
423
|
end
|
|
@@ -31,6 +31,54 @@ module Railwatch
|
|
|
31
31
|
end
|
|
32
32
|
end
|
|
33
33
|
|
|
34
|
+
# SQLite's auto_vacuum modes. Deleted pages only leave the file in
|
|
35
|
+
# :incremental, and only when PRAGMA incremental_vacuum asks for them;
|
|
36
|
+
# :none (the default, and what every database created before
|
|
37
|
+
# EnableIncrementalVacuum is in) keeps them on the freelist forever.
|
|
38
|
+
AUTO_VACUUM_MODES = { 0 => :none, 1 => :full, 2 => :incremental }.freeze
|
|
39
|
+
|
|
40
|
+
# Everything below reads and writes THIS database, never the host's. The
|
|
41
|
+
# pragmas are per-database and there is no Active Record wrapper for them,
|
|
42
|
+
# so they go through this class's own connection on purpose: through
|
|
43
|
+
# ActiveRecord::Base they would report on, and vacuum, the application's
|
|
44
|
+
# primary database instead.
|
|
45
|
+
def self.sqlite? = connection.adapter_name.match?(/sqlite/i)
|
|
46
|
+
|
|
47
|
+
def self.auto_vacuum_mode = sqlite? ? AUTO_VACUUM_MODES.fetch(connection.select_value("PRAGMA auto_vacuum").to_i, :unknown) : nil
|
|
48
|
+
|
|
49
|
+
def self.freelist_pages = sqlite? ? connection.select_value("PRAGMA freelist_count").to_i : 0
|
|
50
|
+
|
|
51
|
+
def self.page_size = sqlite? ? connection.select_value("PRAGMA page_size").to_i : 0
|
|
52
|
+
|
|
53
|
+
# Hands freelist pages back to the filesystem, a slice at a time. Each
|
|
54
|
+
# PRAGMA is its own implicit transaction, so the write lock is taken and
|
|
55
|
+
# released once per slice rather than held for the whole reclaim -- the
|
|
56
|
+
# same reason PruneTelemetryJob deletes in bounded batches instead of one
|
|
57
|
+
# statement. Returns the pages actually reclaimed, which is 0 on a
|
|
58
|
+
# database that is not in incremental mode: there the pragma is accepted
|
|
59
|
+
# and does nothing, and calling it would look like work that happened.
|
|
60
|
+
def self.reclaim_freelist!(slice:, slices:)
|
|
61
|
+
return 0 unless sqlite? && auto_vacuum_mode == :incremental
|
|
62
|
+
|
|
63
|
+
before = freelist_pages
|
|
64
|
+
slices.times do
|
|
65
|
+
break if freelist_pages.zero?
|
|
66
|
+
|
|
67
|
+
incremental_vacuum(slice.to_i)
|
|
68
|
+
end
|
|
69
|
+
before - freelist_pages
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Through the raw connection on purpose. incremental_vacuum frees one page
|
|
73
|
+
# per sqlite3_step, and Active Record's execute steps a statement with no
|
|
74
|
+
# result columns exactly once -- so through it this pragma frees a single
|
|
75
|
+
# page whatever count it is handed, which is the kind of no-op that reads
|
|
76
|
+
# as work done. The sqlite3 gem's own execute steps to completion. Same
|
|
77
|
+
# raw_connection route Ingest::Writer already takes for its inserts.
|
|
78
|
+
def self.incremental_vacuum(pages = nil)
|
|
79
|
+
connection.raw_connection.execute("PRAGMA incremental_vacuum#{"(#{pages})" if pages}")
|
|
80
|
+
end
|
|
81
|
+
|
|
34
82
|
# Records arrive as the gem's wire hashes; this is the shared envelope.
|
|
35
83
|
def self.envelope_columns(t)
|
|
36
84
|
t.datetime :occurred_at, null: false, precision: 6
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Puts the telemetry database into incremental auto-vacuum mode, which is what
|
|
4
|
+
# lets PruneTelemetryJob hand the pages it frees back to the filesystem
|
|
5
|
+
# instead of parking them on the freelist forever. Without it the file only
|
|
6
|
+
# ever grows: deleting a day of telemetry frees pages for SQLite to reuse and
|
|
7
|
+
# returns not one byte to the disk.
|
|
8
|
+
#
|
|
9
|
+
# Why a migration, and why this version number. SQLite only lets a database
|
|
10
|
+
# leave auto_vacuum=none for free while it still holds no pages; after that
|
|
11
|
+
# the only way in is a full VACUUM, which rewrites the entire file with the
|
|
12
|
+
# write lock held. Declaring the pragma in config/database.yml cannot do it
|
|
13
|
+
# either: Rails applies its own DEFAULT_PRAGMAS before any declared ones, and
|
|
14
|
+
# `journal_mode = wal` writes the file header, so by the time
|
|
15
|
+
# `auto_vacuum = incremental` runs the database is no longer new and the
|
|
16
|
+
# statement is a silent no-op (measured: the mode comes out `none`).
|
|
17
|
+
#
|
|
18
|
+
# A migration is the one hook that runs both on a new install's db:prepare and
|
|
19
|
+
# on an existing install's upgrade, and this one is numbered below
|
|
20
|
+
# CreateTelemetry so that on a new database it runs while the file holds
|
|
21
|
+
# nothing but schema_migrations and ar_internal_metadata -- a few kilobytes,
|
|
22
|
+
# where the VACUUM that commits the mode is instant. Rails runs a pending
|
|
23
|
+
# migration whatever its version, so an existing install gets it too; there it
|
|
24
|
+
# does nothing at all, because converting a file that may be tens of gigabytes
|
|
25
|
+
# is not a decision a deploy gets to make. That one is `bin/rails
|
|
26
|
+
# railwatch:vacuum`, run deliberately, which says what it will cost first.
|
|
27
|
+
class EnableIncrementalVacuum < ActiveRecord::Migration[8.1]
|
|
28
|
+
# VACUUM cannot run inside a transaction.
|
|
29
|
+
disable_ddl_transaction!
|
|
30
|
+
|
|
31
|
+
# Rails' own bookkeeping, which is present before the first migration runs
|
|
32
|
+
# and so does not make a database "existing".
|
|
33
|
+
BOOKKEEPING = %w[schema_migrations ar_internal_metadata].freeze
|
|
34
|
+
|
|
35
|
+
def up
|
|
36
|
+
return say("not SQLite (#{connection.adapter_name}); auto_vacuum does not apply") unless sqlite?
|
|
37
|
+
return say("auto_vacuum is already incremental") if mode == "incremental"
|
|
38
|
+
|
|
39
|
+
populated = connection.tables - BOOKKEEPING
|
|
40
|
+
if populated.any?
|
|
41
|
+
return say("#{populated.size} tables already here: left in auto_vacuum=#{mode}. Run " \
|
|
42
|
+
"`bin/rails railwatch:vacuum` to convert this database when you can spare the lock.")
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
connection.execute("PRAGMA auto_vacuum = incremental")
|
|
46
|
+
connection.execute("VACUUM")
|
|
47
|
+
say("auto_vacuum = #{mode}")
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Irreversible in the useful sense: going back to `none` is another whole
|
|
51
|
+
# VACUUM, and nothing about the schema depends on the mode. Rolling back
|
|
52
|
+
# leaves it where it is rather than spending that on an undo nobody asked
|
|
53
|
+
# for.
|
|
54
|
+
def down
|
|
55
|
+
say("auto_vacuum left as #{sqlite? ? mode : connection.adapter_name}; use `PRAGMA auto_vacuum = none; VACUUM;` to undo it by hand")
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
def sqlite? = connection.adapter_name.match?(/sqlite/i)
|
|
60
|
+
|
|
61
|
+
def mode = { 0 => "none", 1 => "full", 2 => "incremental" }[connection.select_value("PRAGMA auto_vacuum").to_i]
|
|
62
|
+
end
|
data/docs/embedded.md
CHANGED
|
@@ -165,7 +165,11 @@ c.base_controller_class = "AdminController" # requires an admin, or redirects
|
|
|
165
165
|
```
|
|
166
166
|
|
|
167
167
|
Your controller's code runs inside the engine, whose route helpers take
|
|
168
|
-
precedence; reach your app's with `main_app.root_path`.
|
|
168
|
+
precedence; reach your app's with `main_app.root_path`. The controller does
|
|
169
|
+
not run for Action Cable subscriptions: live updates stay closed unless
|
|
170
|
+
`dashboard_user` also authorizes the connection (as below). Return `nil` or
|
|
171
|
+
`false` for unauthorized users. The resolver names the operator on pages;
|
|
172
|
+
keep the controller or mount constraint as the page authorization gate.
|
|
169
173
|
|
|
170
174
|
Or a routes constraint, which keeps the engine out of it entirely (for
|
|
171
175
|
example with the sessions Rails' authentication generator creates):
|
|
@@ -183,7 +187,11 @@ purpose:
|
|
|
183
187
|
|
|
184
188
|
```ruby
|
|
185
189
|
c.http_basic_auth_enabled = false
|
|
186
|
-
|
|
190
|
+
# The mount constraint does not cover /cable. Authorize live updates too:
|
|
191
|
+
c.dashboard_user = ->(request) {
|
|
192
|
+
user = Session.find_by(id: request.cookie_jar.signed[:session_id])&.user
|
|
193
|
+
{ id: user.id, name: user.name } if user&.admin?
|
|
194
|
+
}
|
|
187
195
|
```
|
|
188
196
|
|
|
189
197
|
### Deliberately public
|
|
@@ -201,7 +209,8 @@ With Basic off and none of `base_controller_class`, `dashboard_user` or
|
|
|
201
209
|
forgotten one. It serves the dashboard (a routes constraint it cannot see
|
|
202
210
|
is a legitimate answer) but logs a warning at every boot outside
|
|
203
211
|
development, `railwatch:doctor` reports the gate as undeclared, and live
|
|
204
|
-
updates are refused.
|
|
212
|
+
updates are refused. A custom base controller declares the page gate only;
|
|
213
|
+
live updates still require their own authorization as described below.
|
|
205
214
|
|
|
206
215
|
### Live updates and `/cable`
|
|
207
216
|
|
|
@@ -209,9 +218,11 @@ Action Cable runs on your application's own `/cable` endpoint, not under
|
|
|
209
218
|
the engine's mount, so a routes constraint around `/railwatch` does not
|
|
210
219
|
cover it and a base controller cannot reach it. The live-update channel
|
|
211
220
|
therefore follows what you declared: HTTP Basic credentials are checked
|
|
212
|
-
|
|
213
|
-
`dashboard_open`
|
|
214
|
-
|
|
221
|
+
from the WebSocket handshake, a `dashboard_user` resolver is consulted,
|
|
222
|
+
or `dashboard_open` explicitly permits public live updates. A custom
|
|
223
|
+
`base_controller_class` authorizes pages only and never permits a Cable
|
|
224
|
+
subscription by itself. The gate is rechecked before each live update;
|
|
225
|
+
revocation removes the subscription and notifies the client. The channel carries an ingest ping (a
|
|
215
226
|
timestamp and per-type counts) and never telemetry records.
|
|
216
227
|
|
|
217
228
|
### Naming the operator, and what an id may be
|
|
@@ -322,7 +333,7 @@ The work that keeps the dashboard current happens in two places:
|
|
|
322
333
|
| anomaly scan | every 5 minutes | Anomaly rules, when any are enabled |
|
|
323
334
|
| scheduled tasks | every 10 minutes | Missed and late scheduled tasks |
|
|
324
335
|
| auto-resolve | daily | Resolves issues quiet for 14 days |
|
|
325
|
-
| prune | daily | Deletes telemetry older than `retention_days`, then `ANALYZE` |
|
|
336
|
+
| prune | daily | Deletes telemetry older than `retention_days`, returns the freed pages to the filesystem when incremental auto-vacuum is on (see below), then `ANALYZE` |
|
|
326
337
|
|
|
327
338
|
Because the clock lives in the web process, it keeps running when the
|
|
328
339
|
job worker is down, which is exactly when "scheduled task X missed its
|
|
@@ -375,6 +386,52 @@ telemetry database grows with traffic and sampling and is pruned to
|
|
|
375
386
|
SQLite files in `storage/`, so Litestream or a volume snapshot covers
|
|
376
387
|
them.
|
|
377
388
|
|
|
389
|
+
### Giving the disk back
|
|
390
|
+
|
|
391
|
+
Deleting rows does not shrink a SQLite file on its own. The pages go on
|
|
392
|
+
the database's freelist, where later inserts reuse them, and the file
|
|
393
|
+
stays whatever size it reached. So pruning alone keeps the *contents*
|
|
394
|
+
bounded and lets the *file* grow forever.
|
|
395
|
+
|
|
396
|
+
Railwatch creates its telemetry database in SQLite's
|
|
397
|
+
`auto_vacuum=incremental` mode, and the nightly prune runs `PRAGMA
|
|
398
|
+
incremental_vacuum` after its deletes, which hands those pages back to
|
|
399
|
+
the filesystem. It is bounded -- 2,000 pages a slice, 25 slices, about
|
|
400
|
+
200MB a night at SQLite's 4K default page size -- so a large backlog
|
|
401
|
+
drains over successive nights rather than holding the write lock through
|
|
402
|
+
one enormous reclaim. Nothing to configure.
|
|
403
|
+
|
|
404
|
+
```
|
|
405
|
+
$ bin/rails railwatch:vacuum:status
|
|
406
|
+
Railwatch telemetry database
|
|
407
|
+
file /rails/storage/production_railwatch_telemetry.sqlite3
|
|
408
|
+
size 1.42 GB
|
|
409
|
+
mode auto_vacuum=incremental
|
|
410
|
+
free 312 pages (1.22 MB) on the freelist
|
|
411
|
+
|
|
412
|
+
The nightly prune returns up to 195 MB a night on its own.
|
|
413
|
+
`bin/rails railwatch:vacuum` returns all 1.22 MB now.
|
|
414
|
+
```
|
|
415
|
+
|
|
416
|
+
**Installs created before 0.3.5 report `auto_vacuum=none`, and pruning
|
|
417
|
+
cannot return their space.** SQLite can only set the mode on a database
|
|
418
|
+
that is still empty; on one that has data the only route is a full
|
|
419
|
+
`VACUUM`, which rewrites the entire file with the write lock held.
|
|
420
|
+
Railwatch will not do that to a running application behind your back, so
|
|
421
|
+
upgrading leaves the mode alone and the conversion is a task you run
|
|
422
|
+
when you can spare the lock:
|
|
423
|
+
|
|
424
|
+
```
|
|
425
|
+
$ bin/rails railwatch:vacuum
|
|
426
|
+
```
|
|
427
|
+
|
|
428
|
+
It prints the file, its size and its freelist first, then says how long
|
|
429
|
+
the `VACUUM` should take and that it needs about the file's own size in
|
|
430
|
+
free disk for the temporary copy. Telemetry written while it runs waits
|
|
431
|
+
for it and the dashboard is frozen for the duration, so pick a quiet
|
|
432
|
+
moment. Once converted, the nightly prune keeps up on its own and you
|
|
433
|
+
never need to run it again.
|
|
434
|
+
|
|
378
435
|
## Switching to the cloud later
|
|
379
436
|
|
|
380
437
|
Set a token and drop `c.transport = :local` (or set
|
data/docs/getting-started.md
CHANGED
|
@@ -36,8 +36,6 @@ bin/rails generate railwatch:install \
|
|
|
36
36
|
|
|
37
37
|
- `--prompt-token` reads without echo. `--token-stdin` is available for a
|
|
38
38
|
secret-manager pipe; an already exported `RAILWATCH_TOKEN` is also detected.
|
|
39
|
-
The legacy `--token=` flag warns because command arguments can be visible in
|
|
40
|
-
shell history and process listings.
|
|
41
39
|
- A token is written to `.env` only when Git confirms that `.env` is ignored.
|
|
42
40
|
A tracked or non-ignored dotenv file is refused; use Rails credentials, a
|
|
43
41
|
deployment secret manager, or add `.env` to `.gitignore` first. Token values
|
data/docs/source-maps.md
CHANGED
|
@@ -48,9 +48,7 @@ New default browser issue fingerprints use the resolved original location
|
|
|
48
48
|
when a map is already available. Custom fingerprints are preserved. Late
|
|
49
49
|
uploads improve existing stack displays, Copy for AI, API issue details and
|
|
50
50
|
MCP `get_issue`, while existing occurrences keep their original grouping.
|
|
51
|
-
Missing maps or columns fall back to the raw stack.
|
|
52
|
-
new browser occurrences to include columns; older gem versions discarded
|
|
53
|
-
them and cannot resolve minified locations accurately.
|
|
51
|
+
Missing maps or columns fall back to the raw stack.
|
|
54
52
|
|
|
55
53
|
For a custom release uploader, POST the raw `.map` bytes to
|
|
56
54
|
`/ingest/sourcemaps` with `Content-Type: application/octet-stream`,
|
|
@@ -13,8 +13,6 @@ module Railwatch
|
|
|
13
13
|
|
|
14
14
|
class_option :local, type: :boolean, default: false,
|
|
15
15
|
desc: "Keep telemetry in this app and serve the dashboard at /railwatch: no token, no cloud."
|
|
16
|
-
class_option :token, type: :string,
|
|
17
|
-
desc: "Deprecated: token in process arguments. Prefer --prompt-token, --token-stdin, or RAILWATCH_TOKEN."
|
|
18
16
|
class_option :prompt_token, type: :boolean, default: false,
|
|
19
17
|
desc: "Prompt for the ingest token without echoing it."
|
|
20
18
|
class_option :token_stdin, type: :boolean, default: false,
|
|
@@ -167,10 +165,6 @@ module Railwatch
|
|
|
167
165
|
return if options[:local]
|
|
168
166
|
|
|
169
167
|
token = resolved_token
|
|
170
|
-
if options[:token]
|
|
171
|
-
say("--token exposes #{Railwatch::SecretSafety.token_preview(options[:token])} in process arguments; " \
|
|
172
|
-
"use --prompt-token or --token-stdin next time.", :yellow)
|
|
173
|
-
end
|
|
174
168
|
vars = { TOKEN_VAR => token, URL_VAR => options[:url] }.compact
|
|
175
169
|
return if vars.empty?
|
|
176
170
|
return say(env_instructions(vars), :yellow) unless dotenv_app?
|
|
@@ -463,8 +457,6 @@ module Railwatch
|
|
|
463
457
|
ask("Railwatch ingest token (input hidden):", echo: false)
|
|
464
458
|
elsif options[:token_stdin]
|
|
465
459
|
$stdin.gets
|
|
466
|
-
elsif options[:token]
|
|
467
|
-
options[:token]
|
|
468
460
|
elsif !ENV[TOKEN_VAR].to_s.empty?
|
|
469
461
|
ENV[TOKEN_VAR]
|
|
470
462
|
end
|
data/lib/railwatch/backtrace.rb
CHANGED
|
@@ -145,9 +145,19 @@ module Railwatch
|
|
|
145
145
|
url.start_with?("#{origin}/")
|
|
146
146
|
end
|
|
147
147
|
|
|
148
|
+
MAX_SOURCE_BYTES = 1024 * 1024
|
|
149
|
+
|
|
148
150
|
def source_snippet(path, line, context: 5)
|
|
149
|
-
return nil unless path
|
|
150
|
-
|
|
151
|
+
return nil unless path
|
|
152
|
+
# String backtraces can be assigned by libraries. A lexical Rails.root
|
|
153
|
+
# prefix alone allows ../ and symlinks to disclose files outside it.
|
|
154
|
+
root = File.realpath(app_root) + File::SEPARATOR
|
|
155
|
+
source = File.realpath(path)
|
|
156
|
+
return nil unless source.start_with?(root) && File.file?(source)
|
|
157
|
+
|
|
158
|
+
data = File.binread(source, MAX_SOURCE_BYTES + 1)
|
|
159
|
+
return nil if data.bytesize > MAX_SOURCE_BYTES
|
|
160
|
+
lines = data.force_encoding(Encoding::UTF_8).scrub.lines(chomp: true)
|
|
151
161
|
from = [ line - context - 1, 0 ].max
|
|
152
162
|
to = [ line + context - 1, lines.size - 1 ].min
|
|
153
163
|
(from..to).to_h { |i| [ i + 1, lines[i].to_s[0, 200] ] }
|
|
@@ -407,12 +407,13 @@ module Railwatch
|
|
|
407
407
|
# engine's mount does not cover and a base controller cannot reach, so an
|
|
408
408
|
# undeclared gate refuses rather than assuming.
|
|
409
409
|
def dashboard_channel_allowed?(request)
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
when :open, :controller then true
|
|
414
|
-
else false
|
|
410
|
+
return http_basic_auth_ok?(request) if http_basic_auth_enabled
|
|
411
|
+
if dashboard_user
|
|
412
|
+
return !!(dashboard_user.respond_to?(:call) ? dashboard_user.call(request) : dashboard_user)
|
|
415
413
|
end
|
|
414
|
+
|
|
415
|
+
# A controller name is not evidence of authorization on /cable.
|
|
416
|
+
!!dashboard_open
|
|
416
417
|
end
|
|
417
418
|
|
|
418
419
|
def resolve_dashboard_user(request)
|
data/lib/railwatch/engine.rb
CHANGED
|
@@ -105,8 +105,8 @@ module Railwatch
|
|
|
105
105
|
Rails.logger.warn(
|
|
106
106
|
"[railwatch] the dashboard at the engine's mount has no gate this gem can see: HTTP Basic is off and " \
|
|
107
107
|
"no base_controller_class, dashboard_user or dashboard_open is set. If a routes constraint or your " \
|
|
108
|
-
"network already gates it, set `c.dashboard_open = true`
|
|
109
|
-
"
|
|
108
|
+
"network already gates it, authorize /cable separately with `c.dashboard_user`, or set `c.dashboard_open = true` " \
|
|
109
|
+
"only when the network protects both endpoints. Otherwise anyone who can reach the dashboard can read every query, log line and exception this app records."
|
|
110
110
|
)
|
|
111
111
|
end
|
|
112
112
|
end
|
|
@@ -22,7 +22,14 @@ module Railwatch
|
|
|
22
22
|
# Not `send`: shadowing Object#send on a class makes it impossible to
|
|
23
23
|
# reach the real one, and reads as a coincidence rather than a verb.
|
|
24
24
|
def deliver(claim, producer_id:)
|
|
25
|
-
|
|
25
|
+
# Snapshot once, then verify and use those exact bytes. Configuration
|
|
26
|
+
# can change after Outbox#claim! checked the destination binding.
|
|
27
|
+
token = @config.resolved_export_token.to_s.dup.freeze
|
|
28
|
+
unless Digest::SHA256.hexdigest(token) == claim.token_digest
|
|
29
|
+
return Outcome.new(disposition: :deferred, status: 401, reason: "export credential changed after claim")
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
result = transport_for(claim, token: token).deliver_encoded(
|
|
26
33
|
body: claim.body, expected_count: claim.record_count, batch_id: claim.delivery_id,
|
|
27
34
|
dropped: claim.metadata.fetch("dropped", 0).to_i,
|
|
28
35
|
dropped_bytes: claim.metadata.fetch("dropped_bytes", 0).to_i,
|
|
@@ -54,11 +61,11 @@ module Railwatch
|
|
|
54
61
|
# otherwise post bytes admitted for one receiver to another one -- and,
|
|
55
62
|
# if they belong to different tenants, hand a customer's telemetry to
|
|
56
63
|
# somebody else while marking it delivered.
|
|
57
|
-
def transport_for(claim)
|
|
64
|
+
def transport_for(claim, token:)
|
|
58
65
|
key = [ claim.url, claim.token_digest ]
|
|
59
66
|
@transports[key] ||= begin
|
|
60
67
|
credentialed = @config.dup
|
|
61
|
-
credentialed.token =
|
|
68
|
+
credentialed.token = token
|
|
62
69
|
Transport::Http.new(credentialed, endpoint: claim.url)
|
|
63
70
|
end
|
|
64
71
|
end
|
data/lib/railwatch/reporter.rb
CHANGED
|
@@ -481,19 +481,9 @@ module Railwatch
|
|
|
481
481
|
dropped_bytes: batch.dropped_bytes + dropped_bytes, prepared: true)
|
|
482
482
|
end
|
|
483
483
|
|
|
484
|
-
# Third-party/test transports written before batch idempotency only accept
|
|
485
|
-
# `dropped:`. Keep those working while the HTTP transport receives the
|
|
486
|
-
# stable identity required to replay a request safely.
|
|
487
484
|
def deliver(batch)
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
accepts_dropped_bytes = parameters.any? { |kind, name| kind == :keyrest || name == :dropped_bytes }
|
|
491
|
-
accepts_backpressure = parameters.any? { |kind, name| kind == :keyrest || name == :backpressure_factor }
|
|
492
|
-
keywords = { dropped: batch.dropped }
|
|
493
|
-
keywords[:batch_id] = batch.id if accepts_batch_id
|
|
494
|
-
keywords[:dropped_bytes] = batch.dropped_bytes if accepts_dropped_bytes
|
|
495
|
-
keywords[:backpressure_factor] = @backpressure_factor if accepts_backpressure
|
|
496
|
-
@transport.deliver(batch.records, **keywords)
|
|
485
|
+
@transport.deliver(batch.records, dropped: batch.dropped, batch_id: batch.id,
|
|
486
|
+
dropped_bytes: batch.dropped_bytes, backpressure_factor: @backpressure_factor)
|
|
497
487
|
end
|
|
498
488
|
|
|
499
489
|
def update_backpressure
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "net/http"
|
|
4
|
+
require "openssl"
|
|
4
5
|
require "uri"
|
|
5
6
|
require "pathname"
|
|
6
7
|
require "json"
|
|
@@ -10,6 +11,7 @@ module Railwatch
|
|
|
10
11
|
# during release preparation; it never follows map URLs or HTTP redirects.
|
|
11
12
|
class SourceMaps
|
|
12
13
|
MAX_BYTES = 10 * 1024 * 1024
|
|
14
|
+
MAX_RESPONSE_BYTES = 64 * 1024
|
|
13
15
|
|
|
14
16
|
def initialize(config)
|
|
15
17
|
@config = config
|
|
@@ -46,7 +48,17 @@ module Railwatch
|
|
|
46
48
|
request["X-Railwatch-Deploy"] = @config.deploy
|
|
47
49
|
request["X-Railwatch-Filename"] = filename
|
|
48
50
|
request.body = data
|
|
49
|
-
response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https",
|
|
51
|
+
response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", verify_mode: OpenSSL::SSL::VERIFY_PEER,
|
|
52
|
+
open_timeout: 5, read_timeout: 30, write_timeout: 30) do |http|
|
|
53
|
+
http.request(request) do |result|
|
|
54
|
+
body = String.new(encoding: Encoding::BINARY)
|
|
55
|
+
result.read_body do |chunk|
|
|
56
|
+
raise "Source map response exceeds 64 KiB" if body.bytesize + chunk.bytesize > MAX_RESPONSE_BYTES
|
|
57
|
+
body << chunk
|
|
58
|
+
end
|
|
59
|
+
result.body = body
|
|
60
|
+
end
|
|
61
|
+
end
|
|
50
62
|
unless response.is_a?(Net::HTTPSuccess)
|
|
51
63
|
raise "Source map upload failed (HTTP #{response.code}) for #{filename}"
|
|
52
64
|
end
|
|
@@ -15,7 +15,7 @@ module Railwatch
|
|
|
15
15
|
class MemoryTransport
|
|
16
16
|
attr_reader :batches
|
|
17
17
|
def initialize = @batches = []
|
|
18
|
-
def deliver(records, dropped: 0, batch_id: nil)
|
|
18
|
+
def deliver(records, dropped: 0, batch_id: nil, **)
|
|
19
19
|
@batches << records
|
|
20
20
|
Transport::Http::Result.new(ok: true, status: 200, accepted: records.size, rejected: 0)
|
|
21
21
|
end
|
|
@@ -174,12 +174,22 @@ module Railwatch
|
|
|
174
174
|
# explicitly so a Ruby default change cannot silently weaken ingest.
|
|
175
175
|
options[:verify_mode] = OpenSSL::SSL::VERIFY_PEER if options[:use_ssl]
|
|
176
176
|
Net::HTTP.start(@uri.host, @uri.port, **options) do |http|
|
|
177
|
-
http.request(req)
|
|
177
|
+
http.request(req) do |response|
|
|
178
|
+
body = String.new(encoding: Encoding::BINARY)
|
|
179
|
+
response.read_body do |chunk|
|
|
180
|
+
if body.bytesize + chunk.bytesize > MAX_RESPONSE_BYTES
|
|
181
|
+
raise ResponseTooLarge, "ingest response is larger than #{MAX_RESPONSE_BYTES} bytes"
|
|
182
|
+
end
|
|
183
|
+
body << chunk
|
|
184
|
+
end
|
|
185
|
+
response.body = body
|
|
186
|
+
end
|
|
178
187
|
end
|
|
179
188
|
end
|
|
180
189
|
|
|
181
|
-
#
|
|
182
|
-
# error page
|
|
190
|
+
# Enforced while streaming, before Net::HTTP can buffer an unbounded
|
|
191
|
+
# proxy error page or decompressed response inside the monitored app.
|
|
192
|
+
class ResponseTooLarge < StandardError; end
|
|
183
193
|
MAX_RESPONSE_BYTES = 64 * 1024
|
|
184
194
|
# The longest delay we will take from a receiver, in either spelling.
|
|
185
195
|
MAX_RETRY_AFTER = 86_400
|
|
@@ -193,8 +203,7 @@ module Railwatch
|
|
|
193
203
|
end
|
|
194
204
|
end
|
|
195
205
|
|
|
196
|
-
#
|
|
197
|
-
# socket -- it buffers the whole response before we ever see it.
|
|
206
|
+
# Keep diagnostics smaller still than the bounded response body.
|
|
198
207
|
def summarize(body) = body.to_s.byteslice(0, 200).to_s.scrub
|
|
199
208
|
|
|
200
209
|
# Seconds, or an HTTP date. Anything else is not a delay we can trust,
|