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.
Files changed (86) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +25 -0
  3. data/app/frontend/entrypoints/inertia.tsx +5 -8
  4. data/app/frontend/lib/mount.ts +23 -0
  5. data/app/frontend/lib/railwatch.ts +10 -1
  6. data/docs/configuration.md +22 -5
  7. data/lib/railwatch/buffer.rb +9 -0
  8. data/lib/railwatch/reporter.rb +89 -2
  9. data/lib/railwatch/sampler.rb +10 -2
  10. data/lib/railwatch/version.rb +1 -1
  11. data/lib/railwatch.rb +4 -2
  12. data/public/railwatch/assets/{app-layout-CpEbDy93.js → app-layout-by7G3sa1.js} +1 -1
  13. data/public/railwatch/assets/{arrow-up-DAODKT5N.js → arrow-up-DCZ7emux.js} +1 -1
  14. data/public/railwatch/assets/{braces-DgB4bqp0.js → braces-CykT5_5u.js} +1 -1
  15. data/public/railwatch/assets/{card-C4EFUWG1.js → card-C-PPSA0g.js} +1 -1
  16. data/public/railwatch/assets/{chart-CD6EHDjG.js → chart-Cov79YC5.js} +1 -1
  17. data/public/railwatch/assets/{chart-hover-B5cFOZ24.js → chart-hover-DdK4E-2h.js} +1 -1
  18. data/public/railwatch/assets/{chart-panel-XD8qJDmg.js → chart-panel-BPaYRXJB.js} +1 -1
  19. data/public/railwatch/assets/{code-CuTKEYtT.js → code-BYjC4YZ_.js} +1 -1
  20. data/public/railwatch/assets/{copy-id-COM9oXfT.js → copy-id-LPfoj9cC.js} +1 -1
  21. data/public/railwatch/assets/{cursor-load-more-CleS4nD4.js → cursor-load-more-DSdhgpGl.js} +1 -1
  22. data/public/railwatch/assets/{data-table-Bzj8zcao.js → data-table-CQ_YRWQs.js} +1 -1
  23. data/public/railwatch/assets/{env-layout-Dic4A8Kn.js → env-layout-CdMWbUkm.js} +1 -1
  24. data/public/railwatch/assets/{execution-path-C7Up9w4e.js → execution-path-7MYt-Qbn.js} +1 -1
  25. data/public/railwatch/assets/{filter-bar-B5wbix1y.js → filter-bar-CrzpQ_vD.js} +1 -1
  26. data/public/railwatch/assets/{flamegraph-Dve4TVkl.js → flamegraph-BIFEdTAg.js} +1 -1
  27. data/public/railwatch/assets/{frames-CkDFkUhi.js → frames-C5t-x4f0.js} +1 -1
  28. data/public/railwatch/assets/{index-Dj03Gham.js → index--NQ7us-L.js} +1 -1
  29. data/public/railwatch/assets/{index-DKwz3gSX.js → index-2kgYe_99.js} +1 -1
  30. data/public/railwatch/assets/{index-CgoVIV-m.js → index-4YEPHxtO.js} +1 -1
  31. data/public/railwatch/assets/{index-7fYWYI1v.js → index-AOCC1GDJ.js} +1 -1
  32. data/public/railwatch/assets/{index-wH-xxmJK.js → index-BEo-u0eO.js} +1 -1
  33. data/public/railwatch/assets/{index-CXSEiNWw.js → index-BJ1V3w2N.js} +1 -1
  34. data/public/railwatch/assets/{index-DJZkxYZf.js → index-BPoHsBKb.js} +1 -1
  35. data/public/railwatch/assets/{index-BheGBdI9.js → index-BZzyGUM3.js} +1 -1
  36. data/public/railwatch/assets/{index-B51WiPQg.js → index-BgVq7yKL.js} +1 -1
  37. data/public/railwatch/assets/{index-BxSwNl8H.js → index-BqFGEZvW.js} +1 -1
  38. data/public/railwatch/assets/{index-BA3nScnV.js → index-ByuHJ1CH.js} +1 -1
  39. data/public/railwatch/assets/{index-DwGETVLb.js → index-C2j50AL2.js} +1 -1
  40. data/public/railwatch/assets/{index-D7t5pSOT.js → index-CLEdTkvW.js} +1 -1
  41. data/public/railwatch/assets/{index-Dx7oNB2j.js → index-CWgq8qBu.js} +1 -1
  42. data/public/railwatch/assets/{index-YqmR-RY4.js → index-CXQ7_oVj.js} +1 -1
  43. data/public/railwatch/assets/{index-CEcv5bba.js → index-CmyYrjS2.js} +1 -1
  44. data/public/railwatch/assets/{index-B2hTf0w6.js → index-Cn9_HjDo.js} +1 -1
  45. data/public/railwatch/assets/{index-CG7VCShP.js → index-CsMAm9UK.js} +1 -1
  46. data/public/railwatch/assets/{index-Kxpc-Jju.js → index-D0Kk_oGY.js} +1 -1
  47. data/public/railwatch/assets/{index-DTLmaJha.js → index-DC9ZptJh.js} +1 -1
  48. data/public/railwatch/assets/{index-DqeNWrJc.js → index-DNzg7jmV.js} +1 -1
  49. data/public/railwatch/assets/{index-0glo-UpF.js → index-DQ8uJO5f.js} +1 -1
  50. data/public/railwatch/assets/{index-kX9K5M8z.js → index-Deq14BxB.js} +1 -1
  51. data/public/railwatch/assets/{index-C_3JsXHv.js → index-IaV3Blzp.js} +1 -1
  52. data/public/railwatch/assets/{index-6t2RLBwA.js → index-TYNWcJQs.js} +1 -1
  53. data/public/railwatch/assets/{index-x18ma-AF.js → index-WonH2fcZ.js} +1 -1
  54. data/public/railwatch/assets/{index-CnlT_aCL.js → index-WxWUaH_d.js} +1 -1
  55. data/public/railwatch/assets/{index-D_dUD6LY.js → index-mUNrl4ky.js} +1 -1
  56. data/public/railwatch/assets/{inertia-b7M33AzA.js → inertia-DfNRARuA.js} +13 -13
  57. data/public/railwatch/assets/{json-viewer-C0RZwcC2.js → json-viewer-DMsF5glp.js} +1 -1
  58. data/public/railwatch/assets/{klass-BT-gRTuY.js → klass-CmHYk5rz.js} +1 -1
  59. data/public/railwatch/assets/{origin-identity-ouOEvOlq.js → origin-identity-1R3bqa3R.js} +1 -1
  60. data/public/railwatch/assets/{percentile-picker-BYFo11OE.js → percentile-picker-73z5PAps.js} +1 -1
  61. data/public/railwatch/assets/{relative-time-CJX4HXBB.js → relative-time-Dsc_cCdX.js} +1 -1
  62. data/public/railwatch/assets/{release-health-CIxeXQ_e.js → release-health-BV5OaOWd.js} +1 -1
  63. data/public/railwatch/assets/{route-eUFnanVO.js → route-CWh58C7K.js} +1 -1
  64. data/public/railwatch/assets/{segmented-CJq8WOHZ.js → segmented-DB4zh-Am.js} +1 -1
  65. data/public/railwatch/assets/{select-vYjStP1q.js → select-DBKjFlOX.js} +1 -1
  66. data/public/railwatch/assets/{series-chart-FBOfRLhK.js → series-chart-BaiDG795.js} +1 -1
  67. data/public/railwatch/assets/{show-zaB17ZGC.js → show-B4ICCFGh.js} +1 -1
  68. data/public/railwatch/assets/{show-BJcTlqdU.js → show-B5lOU9Gv.js} +1 -1
  69. data/public/railwatch/assets/{show-DGPuUSHE.js → show-BYPB74t4.js} +1 -1
  70. data/public/railwatch/assets/{show-DNY79oEl.js → show-C7A0m2M8.js} +1 -1
  71. data/public/railwatch/assets/{show-D7fBqLw0.js → show-DT5X1zqE.js} +1 -1
  72. data/public/railwatch/assets/{show-C7w8Tnt2.js → show-D_NBdXsn.js} +1 -1
  73. data/public/railwatch/assets/{show-BBNCHyV9.js → show-DgPCnRg8.js} +1 -1
  74. data/public/railwatch/assets/{show-C5SMVt6Z.js → show-Yl69HF0x.js} +1 -1
  75. data/public/railwatch/assets/{show-daCAn8qV.js → show-_QxuUQCm.js} +1 -1
  76. data/public/railwatch/assets/{show-DeTnRall.js → show-g__jnN36.js} +1 -1
  77. data/public/railwatch/assets/{show-Bq4w9YtT.js → show-l4ztfjl9.js} +1 -1
  78. data/public/railwatch/assets/{sort-header-dslBMND2.js → sort-header-BcG8ulEH.js} +1 -1
  79. data/public/railwatch/assets/{sparkline-cell-C7fhjJby.js → sparkline-cell-B47ipscB.js} +1 -1
  80. data/public/railwatch/assets/{stat-nY8iS5q9.js → stat-DT2hhsqS.js} +1 -1
  81. data/public/railwatch/assets/{status-badge-D40D8TiV.js → status-badge-CmbRKlY7.js} +1 -1
  82. data/public/railwatch/assets/{tenant-path-UhieazCc.js → tenant-path-C4rNGauE.js} +1 -1
  83. data/public/railwatch/assets/{use-clipboard-T9kCBcs8.js → use-clipboard-oTNeP4ax.js} +1 -1
  84. data/public/railwatch/assets/{use-live-rp6jfGac.js → use-live-CLZZj245.js} +1 -1
  85. data/public/railwatch/manifest.json +611 -611
  86. metadata +75 -74
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f68b0938b3d6520230abbfb7fbb4f4fac4ed1f052a6d8a4c57c24152ef364d27
4
- data.tar.gz: a6807b8d823cadb1c02d5f22ae045694470aa4e274b998549168b7ce89b2ee22
3
+ metadata.gz: bfbb6b3ab550297baaa042889c6e98fe0b5fc1528acf6696fa343631b05936bd
4
+ data.tar.gz: 37b7b7bce05a1e0b19ed8051f9dcc7cd09f3ca0c8d638404c9fa55bb227c44c4
5
5
  SHA512:
6
- metadata.gz: eb3ba23b7ecb61dda1792c3d19e6dfe3d6a8f94d87159d476962e6b4430e6914aa88517fe4bb52a1440415948df1fb408f99bf028e546b317e427e86d6561078
7
- data.tar.gz: 9fc99ae33e5b538ef7ac8c92a043fa8ee688b7869ae344109254334654a1b96a7b7f048078b0c97ffaf165208b98128c2eae9f8556e4b219db08dbea91036868
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 by the embedded engine, every route helper must carry the
12
- // engine's mount point. The engine layout emits it; the hosted platform does
13
- // not, so this is a no-op there.
14
- const mount = document.querySelector<HTMLMetaElement>(
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
- return "/railwatch/beacon"
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() {
@@ -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 401 marks the transport
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 shutdown expires with retained records
831
- that could not be sent. Retryable delivery failures stay buffered and do
832
- not fire the callback on every attempt. With no callback registered, a
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. Telemetry that vanishes silently looks
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.
@@ -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
@@ -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
@@ -8,10 +8,18 @@ module Railwatch
8
8
  module_function
9
9
 
10
10
  def decide(kind)
11
- rate = Railwatch.config.sample_rate(kind) / Railwatch.reporter.backpressure_factor
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
- Random.rand < rate
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Railwatch
4
- VERSION = "0.9.2"
4
+ VERSION = "0.9.4"
5
5
  end
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, or the ones still unsent
359
- # when at_exit's bounded shutdown ran out of time -- and since 0.3.7 that
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