railwatch 0.9.2 → 0.9.4
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 +25 -0
- data/app/frontend/entrypoints/inertia.tsx +5 -8
- data/app/frontend/lib/mount.ts +23 -0
- data/app/frontend/lib/railwatch.ts +10 -1
- data/docs/configuration.md +22 -5
- data/lib/railwatch/buffer.rb +9 -0
- data/lib/railwatch/reporter.rb +89 -2
- data/lib/railwatch/sampler.rb +10 -2
- data/lib/railwatch/version.rb +1 -1
- data/lib/railwatch.rb +4 -2
- data/public/railwatch/assets/{app-layout-CpEbDy93.js → app-layout-by7G3sa1.js} +1 -1
- data/public/railwatch/assets/{arrow-up-DAODKT5N.js → arrow-up-DCZ7emux.js} +1 -1
- data/public/railwatch/assets/{braces-DgB4bqp0.js → braces-CykT5_5u.js} +1 -1
- data/public/railwatch/assets/{card-C4EFUWG1.js → card-C-PPSA0g.js} +1 -1
- data/public/railwatch/assets/{chart-CD6EHDjG.js → chart-Cov79YC5.js} +1 -1
- data/public/railwatch/assets/{chart-hover-B5cFOZ24.js → chart-hover-DdK4E-2h.js} +1 -1
- data/public/railwatch/assets/{chart-panel-XD8qJDmg.js → chart-panel-BPaYRXJB.js} +1 -1
- data/public/railwatch/assets/{code-CuTKEYtT.js → code-BYjC4YZ_.js} +1 -1
- data/public/railwatch/assets/{copy-id-COM9oXfT.js → copy-id-LPfoj9cC.js} +1 -1
- data/public/railwatch/assets/{cursor-load-more-CleS4nD4.js → cursor-load-more-DSdhgpGl.js} +1 -1
- data/public/railwatch/assets/{data-table-Bzj8zcao.js → data-table-CQ_YRWQs.js} +1 -1
- data/public/railwatch/assets/{env-layout-Dic4A8Kn.js → env-layout-CdMWbUkm.js} +1 -1
- data/public/railwatch/assets/{execution-path-C7Up9w4e.js → execution-path-7MYt-Qbn.js} +1 -1
- data/public/railwatch/assets/{filter-bar-B5wbix1y.js → filter-bar-CrzpQ_vD.js} +1 -1
- data/public/railwatch/assets/{flamegraph-Dve4TVkl.js → flamegraph-BIFEdTAg.js} +1 -1
- data/public/railwatch/assets/{frames-CkDFkUhi.js → frames-C5t-x4f0.js} +1 -1
- data/public/railwatch/assets/{index-Dj03Gham.js → index--NQ7us-L.js} +1 -1
- data/public/railwatch/assets/{index-DKwz3gSX.js → index-2kgYe_99.js} +1 -1
- data/public/railwatch/assets/{index-CgoVIV-m.js → index-4YEPHxtO.js} +1 -1
- data/public/railwatch/assets/{index-7fYWYI1v.js → index-AOCC1GDJ.js} +1 -1
- data/public/railwatch/assets/{index-wH-xxmJK.js → index-BEo-u0eO.js} +1 -1
- data/public/railwatch/assets/{index-CXSEiNWw.js → index-BJ1V3w2N.js} +1 -1
- data/public/railwatch/assets/{index-DJZkxYZf.js → index-BPoHsBKb.js} +1 -1
- data/public/railwatch/assets/{index-BheGBdI9.js → index-BZzyGUM3.js} +1 -1
- data/public/railwatch/assets/{index-B51WiPQg.js → index-BgVq7yKL.js} +1 -1
- data/public/railwatch/assets/{index-BxSwNl8H.js → index-BqFGEZvW.js} +1 -1
- data/public/railwatch/assets/{index-BA3nScnV.js → index-ByuHJ1CH.js} +1 -1
- data/public/railwatch/assets/{index-DwGETVLb.js → index-C2j50AL2.js} +1 -1
- data/public/railwatch/assets/{index-D7t5pSOT.js → index-CLEdTkvW.js} +1 -1
- data/public/railwatch/assets/{index-Dx7oNB2j.js → index-CWgq8qBu.js} +1 -1
- data/public/railwatch/assets/{index-YqmR-RY4.js → index-CXQ7_oVj.js} +1 -1
- data/public/railwatch/assets/{index-CEcv5bba.js → index-CmyYrjS2.js} +1 -1
- data/public/railwatch/assets/{index-B2hTf0w6.js → index-Cn9_HjDo.js} +1 -1
- data/public/railwatch/assets/{index-CG7VCShP.js → index-CsMAm9UK.js} +1 -1
- data/public/railwatch/assets/{index-Kxpc-Jju.js → index-D0Kk_oGY.js} +1 -1
- data/public/railwatch/assets/{index-DTLmaJha.js → index-DC9ZptJh.js} +1 -1
- data/public/railwatch/assets/{index-DqeNWrJc.js → index-DNzg7jmV.js} +1 -1
- data/public/railwatch/assets/{index-0glo-UpF.js → index-DQ8uJO5f.js} +1 -1
- data/public/railwatch/assets/{index-kX9K5M8z.js → index-Deq14BxB.js} +1 -1
- data/public/railwatch/assets/{index-C_3JsXHv.js → index-IaV3Blzp.js} +1 -1
- data/public/railwatch/assets/{index-6t2RLBwA.js → index-TYNWcJQs.js} +1 -1
- data/public/railwatch/assets/{index-x18ma-AF.js → index-WonH2fcZ.js} +1 -1
- data/public/railwatch/assets/{index-CnlT_aCL.js → index-WxWUaH_d.js} +1 -1
- data/public/railwatch/assets/{index-D_dUD6LY.js → index-mUNrl4ky.js} +1 -1
- data/public/railwatch/assets/{inertia-b7M33AzA.js → inertia-DfNRARuA.js} +13 -13
- data/public/railwatch/assets/{json-viewer-C0RZwcC2.js → json-viewer-DMsF5glp.js} +1 -1
- data/public/railwatch/assets/{klass-BT-gRTuY.js → klass-CmHYk5rz.js} +1 -1
- data/public/railwatch/assets/{origin-identity-ouOEvOlq.js → origin-identity-1R3bqa3R.js} +1 -1
- data/public/railwatch/assets/{percentile-picker-BYFo11OE.js → percentile-picker-73z5PAps.js} +1 -1
- data/public/railwatch/assets/{relative-time-CJX4HXBB.js → relative-time-Dsc_cCdX.js} +1 -1
- data/public/railwatch/assets/{release-health-CIxeXQ_e.js → release-health-BV5OaOWd.js} +1 -1
- data/public/railwatch/assets/{route-eUFnanVO.js → route-CWh58C7K.js} +1 -1
- data/public/railwatch/assets/{segmented-CJq8WOHZ.js → segmented-DB4zh-Am.js} +1 -1
- data/public/railwatch/assets/{select-vYjStP1q.js → select-DBKjFlOX.js} +1 -1
- data/public/railwatch/assets/{series-chart-FBOfRLhK.js → series-chart-BaiDG795.js} +1 -1
- data/public/railwatch/assets/{show-zaB17ZGC.js → show-B4ICCFGh.js} +1 -1
- data/public/railwatch/assets/{show-BJcTlqdU.js → show-B5lOU9Gv.js} +1 -1
- data/public/railwatch/assets/{show-DGPuUSHE.js → show-BYPB74t4.js} +1 -1
- data/public/railwatch/assets/{show-DNY79oEl.js → show-C7A0m2M8.js} +1 -1
- data/public/railwatch/assets/{show-D7fBqLw0.js → show-DT5X1zqE.js} +1 -1
- data/public/railwatch/assets/{show-C7w8Tnt2.js → show-D_NBdXsn.js} +1 -1
- data/public/railwatch/assets/{show-BBNCHyV9.js → show-DgPCnRg8.js} +1 -1
- data/public/railwatch/assets/{show-C5SMVt6Z.js → show-Yl69HF0x.js} +1 -1
- data/public/railwatch/assets/{show-daCAn8qV.js → show-_QxuUQCm.js} +1 -1
- data/public/railwatch/assets/{show-DeTnRall.js → show-g__jnN36.js} +1 -1
- data/public/railwatch/assets/{show-Bq4w9YtT.js → show-l4ztfjl9.js} +1 -1
- data/public/railwatch/assets/{sort-header-dslBMND2.js → sort-header-BcG8ulEH.js} +1 -1
- data/public/railwatch/assets/{sparkline-cell-C7fhjJby.js → sparkline-cell-B47ipscB.js} +1 -1
- data/public/railwatch/assets/{stat-nY8iS5q9.js → stat-DT2hhsqS.js} +1 -1
- data/public/railwatch/assets/{status-badge-D40D8TiV.js → status-badge-CmbRKlY7.js} +1 -1
- data/public/railwatch/assets/{tenant-path-UhieazCc.js → tenant-path-C4rNGauE.js} +1 -1
- data/public/railwatch/assets/{use-clipboard-T9kCBcs8.js → use-clipboard-oTNeP4ax.js} +1 -1
- data/public/railwatch/assets/{use-live-rp6jfGac.js → use-live-CLZZj245.js} +1 -1
- data/public/railwatch/manifest.json +611 -611
- metadata +75 -74
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: bfbb6b3ab550297baaa042889c6e98fe0b5fc1528acf6696fa343631b05936bd
|
|
4
|
+
data.tar.gz: 37b7b7bce05a1e0b19ed8051f9dcc7cd09f3ca0c8d638404c9fa55bb227c44c4
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: dc66f26f906bdab5b2be01c151c81e40767004508f94c1c7860d0a590272daa7469ec3a1a66cb8d5992e1f8ff7f080d6280049afe932b0582859b693c2801655
|
|
7
|
+
data.tar.gz: 9296b26cf0c6c0eb837fdc85f7b69cd4f589c2ebfe5579e30401d3b16819e653dd86ad52764cff5d2ab6e768bf72c5b505ec4255d128a0f3334bfcefe0c41b72
|
data/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,31 @@
|
|
|
6
6
|
filled in by the release commit, which is also the only commit that
|
|
7
7
|
touches lib/railwatch/version.rb and Gemfile.lock. See CONTRIBUTING.md. -->
|
|
8
8
|
|
|
9
|
+
## 0.9.4 (2026-10-22)
|
|
10
|
+
|
|
11
|
+
- Sampling recovers as soon as ingest does. A receiver refusing with a
|
|
12
|
+
`Retry-After` (a Railwatch server below its disk floor answers 503 with
|
|
13
|
+
`Retry-After: 60`) kept the app at 1/8 sampling for 50-60 s after it was
|
|
14
|
+
accepting again: the factor only halved per accepted delivery, and those
|
|
15
|
+
waited on 32-60 s backoff rungs. A refusal is now retried after its
|
|
16
|
+
`Retry-After` or 7.5-15 s, whichever is sooner (never sooner than the
|
|
17
|
+
backoff rung), and the first accepted delivery puts the factor back to
|
|
18
|
+
1x.
|
|
19
|
+
- A refusal is no longer silent. One `[railwatch]` line when ingest starts
|
|
20
|
+
refusing, and one when it accepts again, with how many executions
|
|
21
|
+
backpressure sampled out and how many records were dropped meanwhile.
|
|
22
|
+
Both go through `on_unrecoverable` / `warn_on_data_loss`, like lost
|
|
23
|
+
batches.
|
|
24
|
+
|
|
25
|
+
## 0.9.3 (2026-10-18)
|
|
26
|
+
|
|
27
|
+
- The dashboard's route helpers and its browser beacon follow the
|
|
28
|
+
`railwatch-mount` meta tag wherever the page is served from: the embedded
|
|
29
|
+
engine mounted somewhere other than `/railwatch`, or a Railwatch server
|
|
30
|
+
mounted inside another app under a base path. The mount is read by one
|
|
31
|
+
helper (`lib/mount.ts`) that refuses anything but a single absolute path, so
|
|
32
|
+
a tag like `//host` cannot point links or the beacon at another origin.
|
|
33
|
+
|
|
9
34
|
## 0.9.2 (2026-10-17)
|
|
10
35
|
|
|
11
36
|
- Changing the dashboard's window (1h/6h/24h/7d, a custom range, step or
|
|
@@ -5,16 +5,13 @@ import { createRoot } from "react-dom/client"
|
|
|
5
5
|
|
|
6
6
|
import { initializeTheme } from "@/hooks/use-appearance"
|
|
7
7
|
import PersistentLayout from "@/layouts/persistent-layout"
|
|
8
|
+
import { applyMount } from "@/lib/mount"
|
|
8
9
|
import { railwatchRootOptions, startRailwatch } from "@/lib/railwatch"
|
|
9
|
-
import { configure as configureRoutes } from "@/routes"
|
|
10
10
|
|
|
11
|
-
// When served
|
|
12
|
-
//
|
|
13
|
-
// not, so this is a no-op there.
|
|
14
|
-
|
|
15
|
-
'meta[name="railwatch-mount"]',
|
|
16
|
-
)?.content
|
|
17
|
-
if (mount) configureRoutes({ default_url_options: { script_name: mount } })
|
|
11
|
+
// When served under a mount point (the embedded engine, or a Railwatch server
|
|
12
|
+
// mounted inside another app), every route helper must carry it. The layout
|
|
13
|
+
// emits it; the hosted platform does not, so this is a no-op there.
|
|
14
|
+
applyMount()
|
|
18
15
|
|
|
19
16
|
const appName = import.meta.env.VITE_APP_NAME ?? "Railwatch"
|
|
20
17
|
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { configure } from "@/routes"
|
|
2
|
+
|
|
3
|
+
// The path the dashboard is served under, from the layout's
|
|
4
|
+
// <meta name="railwatch-mount">: the embedded engine's mount point
|
|
5
|
+
// (request.script_name), or the base path of a Railwatch server mounted
|
|
6
|
+
// inside another app. "" without the tag (the hosted platform) or for
|
|
7
|
+
// anything that is not one absolute path: "//host" or "https://..." would
|
|
8
|
+
// point every link at another origin.
|
|
9
|
+
export function mountPath(doc: Document = document): string {
|
|
10
|
+
const raw =
|
|
11
|
+
doc.querySelector<HTMLMetaElement>('meta[name="railwatch-mount"]')
|
|
12
|
+
?.content ?? ""
|
|
13
|
+
const path = raw.trim().replace(/\/+$/, "")
|
|
14
|
+
return /^\/(?![/\\])/.test(path) ? path : ""
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Every route helper carries the mount point (js-routes' script_name).
|
|
18
|
+
// Returns the mount, "" when there is none and nothing changed.
|
|
19
|
+
export function applyMount(doc: Document = document): string {
|
|
20
|
+
const mount = mountPath(doc)
|
|
21
|
+
if (mount) configure({ default_url_options: { script_name: mount } })
|
|
22
|
+
return mount
|
|
23
|
+
}
|
|
@@ -127,8 +127,17 @@ let foregroundRequest: { id?: string } | null = null
|
|
|
127
127
|
// whether or not the first was reported.
|
|
128
128
|
const requestFailures = new WeakSet<object>()
|
|
129
129
|
|
|
130
|
+
// The engine's beacon, under the dashboard's mount point when the page has
|
|
131
|
+
// one (<meta name="railwatch-mount">, lib/mount.ts; inlined because this
|
|
132
|
+
// file is copied into apps on its own). An app's own pages have no such tag
|
|
133
|
+
// and post to the engine at its default mount.
|
|
130
134
|
function endpoint() {
|
|
131
|
-
|
|
135
|
+
const mount =
|
|
136
|
+
document
|
|
137
|
+
.querySelector<HTMLMetaElement>('meta[name="railwatch-mount"]')
|
|
138
|
+
?.content.trim()
|
|
139
|
+
.replace(/\/+$/, "") ?? ""
|
|
140
|
+
return /^\/(?![/\\])/.test(mount) ? `${mount}/beacon` : "/railwatch/beacon"
|
|
132
141
|
}
|
|
133
142
|
|
|
134
143
|
function csrf() {
|
data/docs/configuration.md
CHANGED
|
@@ -411,7 +411,12 @@ result without inserting the payload twice. Records written while a request is i
|
|
|
411
411
|
separate bounded buffer, so they never change the retained request's
|
|
412
412
|
identity. At most one retained batch plus one live buffer are held in
|
|
413
413
|
memory. The reporter retries with jittered exponential backoff, from one
|
|
414
|
-
second up to 60 seconds. It does not busy-loop. A
|
|
414
|
+
second up to 60 seconds. It does not busy-loop. A response with a
|
|
415
|
+
`Retry-After` (a 503 for a full disk or busy database, a 429 rate limit) is a
|
|
416
|
+
refusal: the next attempt waits for its `Retry-After` instead, but never
|
|
417
|
+
less than the backoff rung and never more than 15 seconds (jittered down to
|
|
418
|
+
7.5), because Railwatch's receivers send a fixed 60 there, not a forecast.
|
|
419
|
+
A 401 marks the transport
|
|
415
420
|
permanently unauthorized: no further HTTP attempts for the process's
|
|
416
421
|
lifetime. It and other permanent client rejections are reported through
|
|
417
422
|
`on_unrecoverable`. Delivery never raises into app code.
|
|
@@ -433,6 +438,15 @@ affects one probabilistic decision. Set `backpressure` false to keep the
|
|
|
433
438
|
factor at 1. The current value is sent as
|
|
434
439
|
`X-Railwatch-Backpressure-Factor` whenever it is greater than 1.
|
|
435
440
|
|
|
441
|
+
A refusal from ingest (see above) sets the factor straight to 8x, and the
|
|
442
|
+
first delivery accepted after it sets it straight back to 1x, so sampling
|
|
443
|
+
recovers within one retry of the receiver recovering. Each refusal prints
|
|
444
|
+
two `[railwatch]` lines, the same way lost records do (see
|
|
445
|
+
`warn_on_data_loss`): one when it starts, and one when ingest accepts
|
|
446
|
+
again, with how long it lasted, how many executions backpressure sampled
|
|
447
|
+
out that the configured rates would have kept, and how many records were
|
|
448
|
+
dropped.
|
|
449
|
+
|
|
436
450
|
`Railwatch.flush` forces an immediate flush, on the calling thread and
|
|
437
451
|
without a time limit, so it is yours to call when you know you want to
|
|
438
452
|
wait. Nothing in the gem calls it for you: a short-lived process keeps its
|
|
@@ -827,16 +841,19 @@ Railwatch.on_unrecoverable { |error| Rails.error.report(error, handled: true) }
|
|
|
827
841
|
```
|
|
828
842
|
|
|
829
843
|
Called whenever Railwatch rescues one of its own internal errors, ingest
|
|
830
|
-
permanently rejects a batch, or
|
|
831
|
-
that could not be sent. Retryable
|
|
832
|
-
|
|
844
|
+
permanently rejects a batch, ingest starts or stops refusing deliveries, or
|
|
845
|
+
shutdown expires with retained records that could not be sent. Retryable
|
|
846
|
+
delivery failures stay buffered and do not fire the callback on every
|
|
847
|
+
attempt. With no callback registered, a
|
|
833
848
|
recovered internal error falls back to `Railwatch.debug`, gated on
|
|
834
849
|
`RAILWATCH_DEBUG` — the gem carried on and there is nothing to do about it.
|
|
835
850
|
|
|
836
851
|
**Lost records are different, and are reported by default.** A batch
|
|
837
852
|
dropped after its retry ladder, one the receiver permanently refused, or
|
|
838
853
|
records still unsent when the bounded shutdown ran out of time each print
|
|
839
|
-
one `[railwatch]` stderr line.
|
|
854
|
+
one `[railwatch]` stderr line. So do the start and end of an ingest
|
|
855
|
+
refusal, which samples down until it is over (see backpressure above).
|
|
856
|
+
Telemetry that vanishes silently looks
|
|
840
857
|
exactly like having nothing to report, and a short-lived process — a rake
|
|
841
858
|
task, a `rails runner`, a cron job — gets one bounded shutdown and no
|
|
842
859
|
second chance to mention it.
|
data/lib/railwatch/buffer.rb
CHANGED
|
@@ -20,6 +20,10 @@ module Railwatch
|
|
|
20
20
|
@bytes = 0
|
|
21
21
|
@dropped = 0
|
|
22
22
|
@dropped_bytes = 0
|
|
23
|
+
# Every record this queue has ever had to drop for room. Unlike
|
|
24
|
+
# @dropped it is never drained, so a caller can difference it across a
|
|
25
|
+
# span of time.
|
|
26
|
+
@overflowed = 0
|
|
23
27
|
@mutex = Mutex.new
|
|
24
28
|
end
|
|
25
29
|
|
|
@@ -58,6 +62,10 @@ module Railwatch
|
|
|
58
62
|
@mutex.synchronize { @bytes }
|
|
59
63
|
end
|
|
60
64
|
|
|
65
|
+
def overflowed
|
|
66
|
+
@mutex.synchronize { @overflowed }
|
|
67
|
+
end
|
|
68
|
+
|
|
61
69
|
def stats
|
|
62
70
|
@mutex.synchronize { [ @records.size, @dropped, @bytes, @dropped_bytes ] }
|
|
63
71
|
end
|
|
@@ -110,6 +118,7 @@ module Railwatch
|
|
|
110
118
|
def drop(bytes)
|
|
111
119
|
@dropped += 1
|
|
112
120
|
@dropped_bytes += bytes
|
|
121
|
+
@overflowed += 1
|
|
113
122
|
end
|
|
114
123
|
|
|
115
124
|
def drop_oldest
|
data/lib/railwatch/reporter.rb
CHANGED
|
@@ -13,7 +13,8 @@ module Railwatch
|
|
|
13
13
|
# counted) so the buffer's newest records win again. Without the cap a
|
|
14
14
|
# batch that keeps failing would be pinned forever while everything
|
|
15
15
|
# newer was discarded around it. Eight attempts on the backoff ladder is
|
|
16
|
-
# roughly four minutes of outage
|
|
16
|
+
# roughly four minutes of outage; one to two when every attempt is
|
|
17
|
+
# refused with a Retry-After (MAX_REFUSED_RETRY_DELAY).
|
|
17
18
|
MAX_RETRY_ATTEMPTS = 8
|
|
18
19
|
# An unhandled exception asks for an immediate flush so it reaches the
|
|
19
20
|
# platform without waiting out flush_interval. "Immediate" is this many
|
|
@@ -26,7 +27,23 @@ module Railwatch
|
|
|
26
27
|
# is enough to turn a saturated stream into breathing room without the
|
|
27
28
|
# long recovery and sparse telemetry a 16x cap would impose.
|
|
28
29
|
MAX_BACKPRESSURE_FACTOR = 8.0
|
|
30
|
+
# A receiver that answers with a Retry-After (503 "disk almost full" or
|
|
31
|
+
# "database busy", 429 "rate limit exceeded") is refusing, not failing.
|
|
32
|
+
# The next attempt waits for its Retry-After instead of the ladder, up
|
|
33
|
+
# to this long. Railwatch's receivers send 15 s for a busy database,
|
|
34
|
+
# honoured as given, and 60 s for the disk floor and the rate-limit
|
|
35
|
+
# window, which are not forecasts: the floor is re-checked every 10 s
|
|
36
|
+
# and lifts the moment space is back, and a window can end at any time.
|
|
37
|
+
# The ladder alone was worse than either: a minute into a refusal its
|
|
38
|
+
# rungs were 16-60 s apart, and a recovered app stayed sampled down
|
|
39
|
+
# until the next one. A longer wait is cut to between half and all of
|
|
40
|
+
# this, jittered so processes refused together do not return together:
|
|
41
|
+
# one POST per process every 7.5-15 s.
|
|
42
|
+
MAX_REFUSED_RETRY_DELAY = 15.0
|
|
29
43
|
DeliveryBatch = Data.define(:id, :records, :bytes, :dropped, :dropped_bytes, :prepared)
|
|
44
|
+
# From the first refusal to the next accepted delivery: when it began,
|
|
45
|
+
# what the receiver said, and the loss counters as they stood then.
|
|
46
|
+
Refusal = Struct.new(:started_at, :reason, :overflowed, :sampled_out, :gave_up)
|
|
30
47
|
|
|
31
48
|
class DeliveryError < StandardError
|
|
32
49
|
attr_reader :status, :records, :bytes, :dropped, :dropped_bytes
|
|
@@ -66,6 +83,8 @@ module Railwatch
|
|
|
66
83
|
@in_flight_bytes = 0
|
|
67
84
|
@in_flight_dropped_bytes = 0
|
|
68
85
|
@shutdown_notified = false
|
|
86
|
+
@refusal = nil
|
|
87
|
+
@sampled_out = 0
|
|
69
88
|
end
|
|
70
89
|
|
|
71
90
|
def buffer
|
|
@@ -75,6 +94,13 @@ module Railwatch
|
|
|
75
94
|
|
|
76
95
|
attr_reader :backpressure_factor
|
|
77
96
|
|
|
97
|
+
# The sampler calls this for an execution the configured rate kept and
|
|
98
|
+
# backpressure did not. Only reached while the factor is above one.
|
|
99
|
+
def note_sampled_out
|
|
100
|
+
ensure_process!
|
|
101
|
+
@mutex.synchronize { @sampled_out += 1 }
|
|
102
|
+
end
|
|
103
|
+
|
|
78
104
|
def write(record, bytes = nil)
|
|
79
105
|
ensure_process!
|
|
80
106
|
size = @buffer.push(record, bytes)
|
|
@@ -146,6 +172,8 @@ module Railwatch
|
|
|
146
172
|
@in_flight_bytes = 0
|
|
147
173
|
@in_flight_dropped_bytes = 0
|
|
148
174
|
@shutdown_notified = false
|
|
175
|
+
@refusal = nil
|
|
176
|
+
@sampled_out = 0
|
|
149
177
|
remove_instance_variable(:@shutdown_deadline) if defined?(@shutdown_deadline)
|
|
150
178
|
self
|
|
151
179
|
end
|
|
@@ -348,6 +376,7 @@ module Railwatch
|
|
|
348
376
|
# raise here and be retained as if it had failed.
|
|
349
377
|
Railwatch.debug { "ingest rejected #{result.rejected} of #{batch.records.size} records" } if result.rejected.to_i.positive?
|
|
350
378
|
delivery_succeeded
|
|
379
|
+
refusal_ended if @refusal
|
|
351
380
|
elsif retryable?(result)
|
|
352
381
|
retain(batch, result)
|
|
353
382
|
else
|
|
@@ -370,22 +399,33 @@ module Railwatch
|
|
|
370
399
|
end
|
|
371
400
|
|
|
372
401
|
def retain(batch, result)
|
|
402
|
+
refused_until = result.retry_after_at if result.respond_to?(:retry_after_at)
|
|
403
|
+
refusal = nil
|
|
373
404
|
gave_up = @mutex.synchronize do
|
|
374
405
|
@in_flight_records = 0
|
|
375
406
|
@in_flight_dropped = 0
|
|
376
407
|
@in_flight_bytes = 0
|
|
377
408
|
@in_flight_dropped_bytes = 0
|
|
409
|
+
if refused_until && !@refusal
|
|
410
|
+
refusal = @refusal = Refusal.new(started_at: Clock.monotonic, reason: failure_detail(result),
|
|
411
|
+
overflowed: @buffer.overflowed, sampled_out: @sampled_out, gave_up: 0)
|
|
412
|
+
# Nothing leaves this buffer until the receiver accepts again, and
|
|
413
|
+
# with retries now seconds apart, so are the flush ticks that would
|
|
414
|
+
# otherwise double the factor up to here. They hold it there.
|
|
415
|
+
@backpressure_factor = MAX_BACKPRESSURE_FACTOR if @config.backpressure
|
|
416
|
+
end
|
|
378
417
|
@retry_attempt += 1
|
|
379
418
|
if @retry_attempt > MAX_RETRY_ATTEMPTS
|
|
380
419
|
@buffer.account_dropped(batch.records.size + batch.dropped,
|
|
381
420
|
bytes: batch.bytes + batch.dropped_bytes)
|
|
421
|
+
@refusal.gave_up += batch.records.size if @refusal
|
|
382
422
|
@retry_attempt = 0
|
|
383
423
|
@retry_at = nil
|
|
384
424
|
Railwatch.debug { "gave up on a batch of #{batch.records.size} records after #{MAX_RETRY_ATTEMPTS} retries (#{result.error || result.status}); dropped and counted" }
|
|
385
425
|
next true
|
|
386
426
|
end
|
|
387
427
|
@retry_batch = batch
|
|
388
|
-
delay = retry_delay(@retry_attempt)
|
|
428
|
+
delay = refused_until ? refused_delay(refused_until) : retry_delay(@retry_attempt)
|
|
389
429
|
@retry_at = Clock.monotonic + delay
|
|
390
430
|
@wakeup.signal
|
|
391
431
|
Railwatch.debug do
|
|
@@ -394,6 +434,17 @@ module Railwatch
|
|
|
394
434
|
end
|
|
395
435
|
false
|
|
396
436
|
end
|
|
437
|
+
# From here backpressure samples out executions the app asked to keep,
|
|
438
|
+
# and the receiver refusing them has no way to count them. Said once
|
|
439
|
+
# per refusal, and refusal_ended says what it cost.
|
|
440
|
+
if refusal
|
|
441
|
+
defer_notification(
|
|
442
|
+
DeliveryError.new("Railwatch ingest is refusing deliveries (#{refusal.reason}); holding " \
|
|
443
|
+
"#{batch.records.size} #{batch.records.size == 1 ? "record" : "records"}" \
|
|
444
|
+
"#{" and sampling down" if @config.backpressure} until it accepts again",
|
|
445
|
+
status: result.status)
|
|
446
|
+
)
|
|
447
|
+
end
|
|
397
448
|
return unless gave_up
|
|
398
449
|
|
|
399
450
|
# Losing a batch is not a debug-level event: with an ingest (or an
|
|
@@ -420,6 +471,42 @@ module Railwatch
|
|
|
420
471
|
)
|
|
421
472
|
end
|
|
422
473
|
|
|
474
|
+
# The first delivery ingest accepted after refusing. The refusal was the
|
|
475
|
+
# pressure, so the divisor goes straight back to one, as it went up,
|
|
476
|
+
# rather than halving over three more flush ticks. A buffer still over
|
|
477
|
+
# its high-water mark raises it again on the next tick, as it would have
|
|
478
|
+
# anyway.
|
|
479
|
+
def refusal_ended
|
|
480
|
+
refusal, sampled_out, overflowed = @mutex.synchronize do
|
|
481
|
+
ended = @refusal
|
|
482
|
+
@refusal = nil
|
|
483
|
+
[ ended, @sampled_out - ended.sampled_out, @buffer.overflowed - ended.overflowed ]
|
|
484
|
+
end
|
|
485
|
+
@backpressure_factor = 1.0
|
|
486
|
+
dropped = overflowed + refusal.gave_up
|
|
487
|
+
seconds = (Clock.monotonic - refusal.started_at).round
|
|
488
|
+
defer_notification(
|
|
489
|
+
DeliveryError.new("Railwatch ingest is accepting deliveries again after refusing for #{seconds}s; " \
|
|
490
|
+
"meanwhile backpressure sampled out #{sampled_out} " \
|
|
491
|
+
"#{sampled_out == 1 ? "execution" : "executions"} and #{dropped} " \
|
|
492
|
+
"#{dropped == 1 ? "record was" : "records were"} dropped",
|
|
493
|
+
records: dropped)
|
|
494
|
+
)
|
|
495
|
+
end
|
|
496
|
+
|
|
497
|
+
# Never sooner than the ladder's rung either, so a receiver answering
|
|
498
|
+
# "Retry-After: 0" gets backoff, not a loop.
|
|
499
|
+
def refused_delay(retry_after_at)
|
|
500
|
+
wanted = retry_after_at - Time.now
|
|
501
|
+
return MAX_REFUSED_RETRY_DELAY * (0.5 + @random.rand * 0.5) if wanted > MAX_REFUSED_RETRY_DELAY
|
|
502
|
+
|
|
503
|
+
[ wanted, retry_delay(@retry_attempt) ].max.clamp(0.0, MAX_REFUSED_RETRY_DELAY)
|
|
504
|
+
end
|
|
505
|
+
|
|
506
|
+
def failure_detail(result)
|
|
507
|
+
[ result.status && "HTTP #{result.status}", result.error ].compact.reject { |part| part.to_s.empty? }.join(": ")
|
|
508
|
+
end
|
|
509
|
+
|
|
423
510
|
def discard_unauthorized
|
|
424
511
|
batch = drain_into_flight
|
|
425
512
|
delivery_succeeded
|
data/lib/railwatch/sampler.rb
CHANGED
|
@@ -8,10 +8,18 @@ module Railwatch
|
|
|
8
8
|
module_function
|
|
9
9
|
|
|
10
10
|
def decide(kind)
|
|
11
|
-
|
|
11
|
+
configured = Railwatch.config.sample_rate(kind)
|
|
12
|
+
rate = configured / Railwatch.reporter.backpressure_factor
|
|
12
13
|
return true if rate >= 1.0
|
|
13
14
|
return false if rate <= 0.0
|
|
14
|
-
|
|
15
|
+
|
|
16
|
+
roll = Random.rand
|
|
17
|
+
return true if roll < rate
|
|
18
|
+
|
|
19
|
+
# Kept by the configured rate, lost to backpressure: counted, so the
|
|
20
|
+
# reporter can say what an ingest refusal cost once it is over.
|
|
21
|
+
Railwatch.reporter.note_sampled_out if roll < configured
|
|
22
|
+
false
|
|
15
23
|
end
|
|
16
24
|
end
|
|
17
25
|
end
|
data/lib/railwatch/version.rb
CHANGED
data/lib/railwatch.rb
CHANGED
|
@@ -355,8 +355,10 @@ module Railwatch
|
|
|
355
355
|
# the gem recovered, and a subscriber that raises once is noise in
|
|
356
356
|
# someone else's cron output. A DeliveryError is different. It is raised
|
|
357
357
|
# only when records are already gone -- a batch dropped after the retry
|
|
358
|
-
# ladder, one the receiver permanently refused,
|
|
359
|
-
# when at_exit's bounded shutdown ran out of time
|
|
358
|
+
# ladder, one the receiver permanently refused, the ones still unsent
|
|
359
|
+
# when at_exit's bounded shutdown ran out of time, or the executions an
|
|
360
|
+
# ingest refusal sampled out (said when it starts, counted when it
|
|
361
|
+
# ends) -- and since 0.3.7 that
|
|
360
362
|
# shutdown is the only delivery a rake task or runner gets. Behind
|
|
361
363
|
# RAILWATCH_DEBUG that loss was invisible: a cron job whose exception
|
|
362
364
|
# never reached the platform looked exactly like one that had nothing to
|