onlylogs 0.9.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -16,12 +16,46 @@ require_relative "spool"
16
16
  # By default an on-disk Spool buffers any batch we could not deliver and replays it once the
17
17
  # drain recovers, so a transient outage or a restart does not lose logs. It is on by default
18
18
  # (set ONLYLOGS_SPOOL_DIR empty to disable) and bounded by bytes; see Onlylogs::Spool.
19
+ #
20
+ # Every write checks that a sender thread is alive in the current process and starts one if not:
21
+ # * The device is usually built in the Puma master (production.rb runs before the workers are
22
+ # forked with preload_app!) and inherited by every worker. Threads do not survive a fork, so
23
+ # the child would have a queue nobody drains, a keep-alive socket shared with its siblings and a
24
+ # spool token that makes siblings overwrite each other's batches. The first write in a new
25
+ # process rebuilds all of that for the child.
26
+ # * The sender must never die, so its error path never raises (see #safe_warn) and every loop
27
+ # iteration is rescued; should it die anyway, the next write restarts it.
28
+ #
29
+ # Not every failure is worth retrying. The drain's answer decides what happens to a batch:
30
+ # * 2xx: delivered.
31
+ # * 429: the drain is overloaded and asks us to slow down. Pause for Retry-After (or a cooldown)
32
+ # and keep the batch on disk; nothing is lost, it is just late.
33
+ # * other 4xx: the drain will never accept this batch (unknown token, paused project, body too
34
+ # big). Retrying cannot help, so the batch is dropped and a warning says why.
35
+ # * 5xx, timeouts, connection errors: retryable. Count towards opening the circuit and spool.
19
36
  module Onlylogs
20
37
  class HttpDevice
38
+ # The drain answered with a 4xx other than 429: the batch itself is the problem, not the drain.
39
+ class Rejected < StandardError; end
40
+
41
+ # The drain answered 429: it is up but wants us to back off.
42
+ class Throttled < StandardError
43
+ attr_reader :retry_after
44
+
45
+ def initialize(message, retry_after: nil)
46
+ super(message)
47
+ @retry_after = retry_after
48
+ end
49
+ end
50
+
21
51
  DEFAULT_BATCH_SIZE = 100
22
52
  DEFAULT_FLUSH_INTERVAL = 0.5
23
53
  DEFAULT_MAX_QUEUE_SIZE = 10_000
24
54
 
55
+ # A batch body never exceeds this many bytes, and neither does a single line: a drain cannot
56
+ # answer 413 to a request we never make. Lines over the cap are cut and marked.
57
+ DEFAULT_MAX_BATCH_BYTES = 1024 * 1024
58
+
25
59
  # Keep timeouts short: a single slow/dead drain must never stall the app for long.
26
60
  DEFAULT_OPEN_TIMEOUT = 0.5
27
61
  DEFAULT_READ_TIMEOUT = 0.5
@@ -32,45 +66,51 @@ module Onlylogs
32
66
 
33
67
  # Open the circuit after this many consecutive failed sends
34
68
  CIRCUIT_FAILURE_THRESHOLD = 3
35
- # ...and keep it open for this long once it is open.
69
+ # ...and keep it open for about this long once it is open. The actual pause is jittered
70
+ # between 0.5x and 1.5x so that every client of a drain that just came back does not retry in
71
+ # the same second.
36
72
  CIRCUIT_COOLDOWN = 30
37
73
 
74
+ # Every setting is validated up front: a typo in an env var must never leave the sender without
75
+ # timeouts (Net::HTTP treats 0 as "no timeout") or unable to truncate a line. Invalid numbers
76
+ # fall back to the default with a warning; a drain URL that is not http(s) or has no host falls
77
+ # back to local-only logging. A misconfiguration is never a boot failure.
38
78
  def initialize(
39
79
  drain_url: ENV["ONLYLOGS_DRAIN_URL"],
40
- batch_size: ENV.fetch("ONLYLOGS_BATCH_SIZE", DEFAULT_BATCH_SIZE).to_i,
41
- flush_interval: ENV.fetch("ONLYLOGS_FLUSH_INTERVAL", DEFAULT_FLUSH_INTERVAL).to_f,
42
- max_queue_size: ENV.fetch("ONLYLOGS_MAX_QUEUE_SIZE", DEFAULT_MAX_QUEUE_SIZE).to_i,
43
- open_timeout: ENV.fetch("ONLYLOGS_OPEN_TIMEOUT", DEFAULT_OPEN_TIMEOUT).to_f,
44
- read_timeout: ENV.fetch("ONLYLOGS_READ_TIMEOUT", DEFAULT_READ_TIMEOUT).to_f,
45
- circuit_cooldown: ENV.fetch("ONLYLOGS_CIRCUIT_COOLDOWN", CIRCUIT_COOLDOWN).to_f,
46
- keep_alive_timeout: ENV.fetch("ONLYLOGS_KEEP_ALIVE_TIMEOUT", DEFAULT_KEEP_ALIVE_TIMEOUT).to_f,
80
+ batch_size: ENV.fetch("ONLYLOGS_BATCH_SIZE", DEFAULT_BATCH_SIZE),
81
+ flush_interval: ENV.fetch("ONLYLOGS_FLUSH_INTERVAL", DEFAULT_FLUSH_INTERVAL),
82
+ max_queue_size: ENV.fetch("ONLYLOGS_MAX_QUEUE_SIZE", DEFAULT_MAX_QUEUE_SIZE),
83
+ max_batch_bytes: ENV.fetch("ONLYLOGS_MAX_BATCH_BYTES", DEFAULT_MAX_BATCH_BYTES),
84
+ open_timeout: ENV.fetch("ONLYLOGS_OPEN_TIMEOUT", DEFAULT_OPEN_TIMEOUT),
85
+ read_timeout: ENV.fetch("ONLYLOGS_READ_TIMEOUT", DEFAULT_READ_TIMEOUT),
86
+ circuit_cooldown: ENV.fetch("ONLYLOGS_CIRCUIT_COOLDOWN", CIRCUIT_COOLDOWN),
87
+ keep_alive_timeout: ENV.fetch("ONLYLOGS_KEEP_ALIVE_TIMEOUT", DEFAULT_KEEP_ALIVE_TIMEOUT),
47
88
  spool_dir: ENV.fetch("ONLYLOGS_SPOOL_DIR", default_spool_dir),
48
- spool_max_bytes: ENV.fetch("ONLYLOGS_SPOOL_MAX_BYTES", Spool::DEFAULT_MAX_BYTES).to_i
89
+ spool_max_bytes: ENV.fetch("ONLYLOGS_SPOOL_MAX_BYTES", Spool::DEFAULT_MAX_BYTES)
49
90
  )
50
- @drain_url = drain_url
51
- @uri = URI.parse(drain_url) if drain_url
52
- @batch_size = batch_size
53
- @flush_interval = flush_interval
54
- @max_queue_size = max_queue_size
55
- @open_timeout = open_timeout
56
- @read_timeout = read_timeout
57
- @circuit_cooldown = circuit_cooldown
58
- @keep_alive_timeout = keep_alive_timeout
59
- @queue = Queue.new
60
- @mutex = Mutex.new
61
- @http_mutex = Mutex.new
62
- @http = nil
63
- @spool = nil
64
-
65
- @consecutive_failures = 0
66
- @circuit_open_until = nil
67
- @dropped = 0
91
+ @uri = parse_drain_url(drain_url)
92
+ @drain_url = drain_url if @uri
93
+ @batch_size = integer_setting("ONLYLOGS_BATCH_SIZE", batch_size, DEFAULT_BATCH_SIZE)
94
+ @flush_interval = float_setting("ONLYLOGS_FLUSH_INTERVAL", flush_interval, DEFAULT_FLUSH_INTERVAL)
95
+ @max_queue_size = integer_setting("ONLYLOGS_MAX_QUEUE_SIZE", max_queue_size, DEFAULT_MAX_QUEUE_SIZE)
96
+ @max_batch_bytes = integer_setting("ONLYLOGS_MAX_BATCH_BYTES", max_batch_bytes, DEFAULT_MAX_BATCH_BYTES,
97
+ min: MIN_BATCH_BYTES)
98
+ @open_timeout = float_setting("ONLYLOGS_OPEN_TIMEOUT", open_timeout, DEFAULT_OPEN_TIMEOUT)
99
+ @read_timeout = float_setting("ONLYLOGS_READ_TIMEOUT", read_timeout, DEFAULT_READ_TIMEOUT)
100
+ @circuit_cooldown = float_setting("ONLYLOGS_CIRCUIT_COOLDOWN", circuit_cooldown, CIRCUIT_COOLDOWN)
101
+ @keep_alive_timeout = float_setting("ONLYLOGS_KEEP_ALIVE_TIMEOUT", keep_alive_timeout, DEFAULT_KEEP_ALIVE_TIMEOUT)
102
+ @spool_dir = spool_dir
103
+ @spool_max_bytes = integer_setting("ONLYLOGS_SPOOL_MAX_BYTES", spool_max_bytes, Spool::DEFAULT_MAX_BYTES)
104
+ @supervisor_mutex = Mutex.new
105
+ reset_process_state
68
106
 
69
107
  if @drain_url
70
- @spool = build_spool(spool_dir, spool_max_bytes)
71
108
  start_sender
72
- else
73
- $stderr.puts "Onlylogs::HttpDevice: ONLYLOGS_DRAIN_URL is not set; logging locally only." # rubocop:disable Style/StderrPuts
109
+ # at_exit procs are inherited by forked children, so this is registered exactly once: a
110
+ # child that rebuilt its state after the fork closes through the same block.
111
+ at_exit { close }
112
+ elsif blank?(drain_url)
113
+ safe_warn "Onlylogs::HttpDevice: ONLYLOGS_DRAIN_URL is not set; logging locally only."
74
114
  end
75
115
  end
76
116
 
@@ -80,21 +120,69 @@ module Onlylogs
80
120
  # No drain configured: nothing to ship. The local fallback (see MultiDevice) still logs it.
81
121
  return unless @drain_url
82
122
 
83
- enqueue(message.chomp)
123
+ ensure_sender
124
+ enqueue(truncate(message.chomp))
84
125
  end
85
126
 
127
+ # Ships everything still queued, then stops the sender. This is the only synchronous path: there
128
+ # is deliberately no #flush, when to ship is the sender's decision (batch size or interval).
86
129
  def close
87
- flush
88
- @running = false
130
+ # A forked child that never logged owns nothing here: the queued lines and the connection
131
+ # belong to the parent, and finishing an inherited TLS socket would send close_notify on it.
132
+ return if forked?
133
+
134
+ @queue.close
89
135
  @sender_thread&.join(2)
90
136
  close_connection
91
137
  end
92
138
 
93
- def flush
94
- send_batch(drain_queue)
139
+ private
140
+
141
+ TRUNCATION_MARKER = "...[truncated by onlylogs]"
142
+
143
+ # A cap that cannot hold the marker would make every truncation raise.
144
+ MIN_BATCH_BYTES = TRUNCATION_MARKER.bytesize + 1
145
+
146
+ def parse_drain_url(url)
147
+ return if blank?(url)
148
+
149
+ uri = URI.parse(url.to_s)
150
+ raise URI::InvalidURIError, "not an http(s) URL with a host" unless uri.is_a?(URI::HTTP) && !blank?(uri.host)
151
+
152
+ uri
153
+ rescue URI::InvalidURIError => e
154
+ safe_warn "Onlylogs::HttpDevice: ONLYLOGS_DRAIN_URL #{url.inspect} is invalid (#{e.message}); logging locally only."
155
+ nil
156
+ end
157
+
158
+ def blank?(value)
159
+ value.nil? || value.to_s.strip.empty?
95
160
  end
96
161
 
97
- private
162
+ def integer_setting(name, value, default, min: 1)
163
+ number = Integer(value, exception: false)
164
+ return number if number && number >= min
165
+
166
+ fallback_setting(name, value, default, "an integer of at least #{min}")
167
+ end
168
+
169
+ def float_setting(name, value, default)
170
+ number = Float(value, exception: false)
171
+ return number if number&.positive?
172
+
173
+ fallback_setting(name, value, default, "a positive number")
174
+ end
175
+
176
+ def fallback_setting(name, value, default, expected)
177
+ safe_warn "Onlylogs::HttpDevice: #{name} is #{value.inspect}, expected #{expected}; using #{default}"
178
+ default
179
+ end
180
+
181
+ def truncate(line)
182
+ return line if line.bytesize <= @max_batch_bytes
183
+
184
+ line.byteslice(0, @max_batch_bytes - TRUNCATION_MARKER.bytesize).scrub("") + TRUNCATION_MARKER
185
+ end
98
186
 
99
187
  # Push a line onto the queue unless it is full. Dropping is intentional: blocking the
100
188
  # caller (a request thread) or growing without bound (OOM) are both worse than losing
@@ -106,47 +194,130 @@ module Onlylogs
106
194
  end
107
195
 
108
196
  @queue << line
197
+ rescue ClosedQueueError
198
+ nil
109
199
  end
110
200
 
111
- def start_sender
112
- @running = true
201
+ # Cheap on the hot path (a getpid and a thread status check); only the first write after a fork
202
+ # or after the sender died pays for the rebuild. Several request threads can race here in a
203
+ # fresh worker, hence the double check under the lock.
204
+ def ensure_sender
205
+ return if sender_healthy?
206
+
207
+ @supervisor_mutex.synchronize do
208
+ next if sender_healthy?
113
209
 
114
- @sender_thread = Thread.new do
115
- # Replay anything left in the spool by a previous run or a crashed/redeployed sibling.
116
- drain_spool
210
+ # Deliberately no close_connection here: after a fork the inherited socket is still in use
211
+ # by the parent, and lines left in the inherited queue are the parent's to ship.
212
+ reset_process_state if forked?
213
+ start_sender
214
+ end
215
+ end
117
216
 
118
- batch = []
119
- last_flush = Time.now
217
+ # A closed queue means #close ran: there is nothing left to supervise.
218
+ def sender_healthy?
219
+ !forked? && (@queue.closed? || @sender_thread&.alive?)
220
+ end
120
221
 
121
- while @running || !@queue.empty?
122
- begin
123
- line = @queue.pop(true)
124
- batch << line if line
125
- rescue ThreadError
126
- # queue empty
127
- end
222
+ def forked?
223
+ Process.pid != @pid
224
+ end
128
225
 
129
- if batch.any? && (batch.size >= @batch_size || (Time.now - last_flush) >= @flush_interval)
130
- send_batch(batch)
226
+ def reset_process_state
227
+ @pid = Process.pid
228
+ @queue = Queue.new
229
+ @mutex = Mutex.new
230
+ @http_mutex = Mutex.new
231
+ @http = nil
232
+ @sender_thread = nil
233
+ @spool = build_spool(@spool_dir, @spool_max_bytes) if @drain_url
234
+ @consecutive_failures = 0
235
+ @circuit_open_until = nil
236
+ @dropped = 0
237
+ @rejected = 0
238
+ @rejection_warned_at = nil
239
+ end
240
+
241
+ def start_sender
242
+ @sender_thread = Thread.new { sender_loop }
243
+ end
244
+
245
+ # Blocks on the queue instead of polling it: a partial batch waits for the rest of the flush
246
+ # interval inside Queue#pop, so the thread costs nothing while idle. A full batch sends early;
247
+ # a closed queue (see #close) ends the loop once it has been emptied.
248
+ #
249
+ # The spool (batches left by an outage, or by a previous run) is replayed one file at a time
250
+ # between live batches, never all at once: the live queue must not overflow while we catch up,
251
+ # and a drain that just recovered must not be hit with every client's whole backlog at full
252
+ # speed. While the queue is idle the loop keeps replaying, one file per turn.
253
+ def sender_loop
254
+ batch = []
255
+ bytes = 0
256
+ deadline = nil
257
+
258
+ loop do
259
+ line = @queue.pop(timeout: pop_timeout(deadline))
260
+ break if line.nil? && @queue.closed?
261
+
262
+ if line
263
+ if batch.any? && bytes + line.bytesize + 1 > @max_batch_bytes
264
+ guard { send_batch(batch) }
131
265
  batch = []
132
- last_flush = Time.now
266
+ bytes = 0
267
+ deadline = nil
133
268
  end
134
-
135
- sleep 0.01 if batch.empty?
269
+ batch << line
270
+ bytes += line.bytesize + 1
271
+ deadline ||= monotonic_now + @flush_interval
136
272
  end
137
273
 
138
- send_batch(batch) if batch.any?
274
+ if batch.any? && (batch.size >= @batch_size || monotonic_now >= deadline)
275
+ guard { send_batch(batch) }
276
+ batch = []
277
+ bytes = 0
278
+ deadline = nil
279
+ guard { replay_one }
280
+ elsif line.nil?
281
+ guard { replay_one }
282
+ end
139
283
  end
140
284
 
141
- at_exit { close }
285
+ guard { send_batch(batch) } if batch.any?
286
+ end
287
+
288
+ # How long the sender may block waiting for the next line: until the partial batch is due,
289
+ # until the circuit closes if there is a backlog to replay, not at all if we can replay right
290
+ # now, or indefinitely when there is nothing to do.
291
+ def pop_timeout(deadline)
292
+ return [deadline - monotonic_now, 0].max if deadline
293
+ return nil unless spool_pending?
294
+
295
+ circuit_remaining
296
+ end
297
+
298
+ def spool_pending?
299
+ !@spool.nil? && !@spool.empty?
300
+ end
301
+
302
+ # Last line of defence for the sender thread: whatever escapes the per-batch handling is
303
+ # reported and the batch given up, never the thread.
304
+ def guard
305
+ yield
306
+ rescue => e
307
+ safe_warn "Onlylogs::HttpDevice sender error: #{e.class}: #{e.message}"
308
+ end
309
+
310
+ # All of the device's own diagnostics go through here. Kernel.warn itself raises when $stderr is
311
+ # a closed pipe or a detached tty (EPIPE, EIO, IOError), and an exception inside an error path
312
+ # would take the sender thread down with it.
313
+ def safe_warn(message)
314
+ Kernel.warn(message)
315
+ rescue
316
+ nil
142
317
  end
143
318
 
144
- def drain_queue
145
- lines = []
146
- lines << @queue.pop(true) until @queue.empty?
147
- lines
148
- rescue ThreadError
149
- lines
319
+ def monotonic_now
320
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
150
321
  end
151
322
 
152
323
  def send_batch(lines)
@@ -164,31 +335,40 @@ module Onlylogs
164
335
 
165
336
  deliver(body)
166
337
  record_success
167
- # The drain just answered: replay anything we had buffered while it was unavailable.
168
- drain_spool
338
+ rescue Rejected => e
339
+ record_rejection(lines.size, e)
340
+ rescue Throttled => e
341
+ record_throttle(e)
342
+ spool_write(body)
169
343
  rescue => e
170
344
  record_failure
171
345
  spool_write(body)
172
- Kernel.warn "Onlylogs::HttpDevice error: #{e.class}: #{e.message}"
346
+ safe_warn "Onlylogs::HttpDevice error: #{e.class}: #{e.message}"
173
347
  end
174
348
 
175
349
  def spool_write(body)
176
350
  @spool&.write(body)
177
351
  end
178
352
 
179
- # Replay buffered batches now that the drain is responding. Oldest first; stop at the first
180
- # failure (record it and leave the rest on disk) so a drain that just went down again does not
181
- # burn the whole backlog into the void.
182
- def drain_spool
183
- return unless @spool
353
+ # Replay the oldest buffered batch, if the drain is believed to be up. A batch the drain rejects
354
+ # for good is deleted too, otherwise it would sit at the head of the spool forever and block
355
+ # everything behind it.
356
+ def replay_one
357
+ return if @spool.nil? || circuit_open?
184
358
 
185
- @spool.replay do |body|
359
+ @spool.replay(limit: 1) do |body|
186
360
  deliver(body)
187
361
  record_success
188
362
  true
363
+ rescue Rejected => e
364
+ record_rejection(body.count("\n") + 1, e)
365
+ true
366
+ rescue Throttled => e
367
+ record_throttle(e)
368
+ false
189
369
  rescue => e
190
370
  record_failure
191
- Kernel.warn "Onlylogs::HttpDevice replay error: #{e.class}: #{e.message}"
371
+ safe_warn "Onlylogs::HttpDevice replay error: #{e.class}: #{e.message}"
192
372
  false
193
373
  end
194
374
  end
@@ -198,7 +378,7 @@ module Onlylogs
198
378
 
199
379
  Spool.new(dir: dir, max_bytes: max_bytes)
200
380
  rescue => e
201
- Kernel.warn "Onlylogs::HttpDevice: spool disabled (#{e.class}: #{e.message})"
381
+ safe_warn "Onlylogs::HttpDevice: spool disabled (#{e.class}: #{e.message})"
202
382
  nil
203
383
  end
204
384
 
@@ -236,13 +416,30 @@ module Onlylogs
236
416
  end
237
417
  end
238
418
 
239
- # Net::HTTP does not raise on 4xx/5xx; it returns the response. Treat any non-2xx as a
240
- # failed delivery so send_batch records it and the circuit can open. Without this a drain
241
- # that is up but answering 500/413 would look like success and we'd silently drop every batch.
419
+ # Net::HTTP does not raise on 4xx/5xx; it returns the response. Every non-2xx raises so the
420
+ # caller can tell a drain that is down (retry) from one that refuses the batch (drop) or asks
421
+ # us to slow down (pause). The drain's body is included: onlylogs.io says why in one line.
242
422
  def ensure_success!(response)
243
423
  return if response.is_a?(Net::HTTPSuccess)
244
424
 
245
- raise "drain responded #{response.code} #{response.message}"
425
+ message = "drain responded #{response.code} #{response.message}"
426
+ detail = response.body.to_s.lines.first.to_s.strip
427
+ message += " (#{detail[0, 80]})" unless detail.empty?
428
+
429
+ case response
430
+ when Net::HTTPTooManyRequests
431
+ raise Throttled.new(message, retry_after: parse_retry_after(response["Retry-After"]))
432
+ when Net::HTTPClientError
433
+ raise Rejected, message
434
+ else
435
+ raise message
436
+ end
437
+ end
438
+
439
+ # Only the delay-seconds form; an HTTP-date is rare and the cooldown is a fine fallback.
440
+ def parse_retry_after(value)
441
+ seconds = Integer(value.to_s, 10, exception: false)
442
+ seconds if seconds&.positive?
246
443
  end
247
444
 
248
445
  def build_request(body)
@@ -279,7 +476,12 @@ module Onlylogs
279
476
  end
280
477
 
281
478
  def circuit_open?
282
- @mutex.synchronize { !@circuit_open_until.nil? && Time.now < @circuit_open_until }
479
+ circuit_remaining.positive?
480
+ end
481
+
482
+ # Seconds until the circuit closes again; 0 when it is closed.
483
+ def circuit_remaining
484
+ @mutex.synchronize { @circuit_open_until ? [@circuit_open_until - Time.now, 0].max : 0 }
283
485
  end
284
486
 
285
487
  def record_success
@@ -290,7 +492,7 @@ module Onlylogs
290
492
  end
291
493
 
292
494
  def record_failure
293
- opened = false
495
+ pause = nil
294
496
  dropped = 0
295
497
 
296
498
  @mutex.synchronize do
@@ -300,18 +502,51 @@ module Onlylogs
300
502
  # (Re)open the circuit. record_failure only runs on a real send attempt — send_batch
301
503
  # short-circuits while the circuit is open — so reaching here always means the drain
302
504
  # is still down and we should pause again (this is how recovery retries every cooldown).
303
- @circuit_open_until = Time.now + @circuit_cooldown
304
- opened = true
505
+ pause = jittered_cooldown
506
+ @circuit_open_until = Time.now + pause
305
507
  dropped = @dropped
306
508
  @dropped = 0
307
509
  end
308
510
 
309
511
  # Warn outside the mutex:
310
512
  # doing it inside the lock would re-enter @mutex through record_failure and raise a recursive-lock error.
311
- return unless opened
513
+ return unless pause
312
514
 
313
515
  suffix = dropped.positive? ? " (#{dropped} log lines dropped)" : ""
314
- Kernel.warn "Onlylogs::HttpDevice: drain unavailable, pausing for #{@circuit_cooldown}s#{suffix}"
516
+ safe_warn "Onlylogs::HttpDevice: drain unavailable, pausing for #{pause.round}s#{suffix}"
517
+ end
518
+
519
+ # A 429 is not an outage: the drain is up and told us how long to wait. Open the circuit for
520
+ # that long without counting a failure, so the batch goes to the spool and is replayed later.
521
+ def record_throttle(error)
522
+ pause = error.retry_after || jittered_cooldown
523
+ @mutex.synchronize { @circuit_open_until = Time.now + pause }
524
+ safe_warn "Onlylogs::HttpDevice: #{error.message}, pausing for #{pause.round}s"
525
+ end
526
+
527
+ # The drain is up (so the circuit stays closed) but refuses this batch for good. One warning
528
+ # per cooldown period, with the running count, rather than one per batch: an unknown token
529
+ # rejects every single batch and would otherwise flood stderr.
530
+ def record_rejection(line_count, error)
531
+ rejected = nil
532
+
533
+ @mutex.synchronize do
534
+ @consecutive_failures = 0
535
+ @rejected += line_count
536
+ next unless @rejection_warned_at.nil? || monotonic_now - @rejection_warned_at >= @circuit_cooldown
537
+
538
+ rejected = @rejected
539
+ @rejected = 0
540
+ @rejection_warned_at = monotonic_now
541
+ end
542
+
543
+ return unless rejected
544
+
545
+ safe_warn "Onlylogs::HttpDevice: #{error.message}, dropped #{rejected} log lines the drain will not accept"
546
+ end
547
+
548
+ def jittered_cooldown
549
+ @circuit_cooldown * (0.5 + rand)
315
550
  end
316
551
  end
317
552
  end
@@ -9,8 +9,10 @@ require_relative "multi_device"
9
9
  #
10
10
  # This is a plain Onlylogs::Logger whose log device is an Onlylogs::HttpDevice teed with the local
11
11
  # fallback. It deliberately does NOT override #add: the stock Logger#add applies the level and
12
- # formats each line before writing, so a below-level line reaches neither sink. All the batching,
13
- # circuit breaking and disk spooling lives in HttpDevice.
12
+ # formats each line before writing, so a below-level line reaches neither sink. Nor #flush: Rails
13
+ # calls it after every request, on the request thread, and it must stay the tag reset it inherits.
14
+ # When to ship is the sender's decision (batch size or interval); all the batching, circuit
15
+ # breaking and disk spooling lives in HttpDevice, and shutdown goes through #close.
14
16
  module Onlylogs
15
17
  class HttpLogger < Onlylogs::Logger
16
18
  attr_reader :device
@@ -20,11 +22,6 @@ module Onlylogs
20
22
  super(MultiDevice.new(local_fallback, @device))
21
23
  end
22
24
 
23
- # Drain the device's in-memory queue to the drain now (tests and graceful shutdown rely on it).
24
- def flush
25
- @device.flush
26
- end
27
-
28
25
  # Only the remote device is ours to close; the local fallback ($stdout) belongs to the app, so
29
26
  # we deliberately do not call super (which would close the whole log device, fallback included).
30
27
  def close