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.
- checksums.yaml +4 -4
- data/README.md +11 -1
- data/app/channels/onlylogs/logs_channel.rb +82 -70
- data/app/javascript/onlylogs/controllers/keyboard_shortcuts_controller.js +15 -11
- data/app/javascript/onlylogs/controllers/log_streamer_controller.js +170 -202
- data/app/javascript/onlylogs/controllers/range_slider_controller.js +96 -0
- data/app/javascript/onlylogs/controllers/text_selection_controller.js +1 -0
- data/app/models/onlylogs/grep.rb +14 -6
- data/app/views/onlylogs/logs/index.html.erb +25 -5
- data/app/views/onlylogs/shared/_log_container.html.erb +71 -35
- data/app/views/onlylogs/shared/_log_container_styles.html.erb +246 -49
- data/app/views/onlylogs/shared/_range_slider.html.erb +9 -5
- data/lib/onlylogs/continuous_log_writer.rb +161 -0
- data/lib/onlylogs/formatter.rb +4 -3
- data/lib/onlylogs/http_device.rb +319 -84
- data/lib/onlylogs/http_logger.rb +4 -7
- data/lib/onlylogs/spool.rb +61 -13
- data/lib/onlylogs/version.rb +1 -1
- metadata +3 -1
data/lib/onlylogs/http_device.rb
CHANGED
|
@@ -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)
|
|
41
|
-
flush_interval: ENV.fetch("ONLYLOGS_FLUSH_INTERVAL", DEFAULT_FLUSH_INTERVAL)
|
|
42
|
-
max_queue_size: ENV.fetch("ONLYLOGS_MAX_QUEUE_SIZE", DEFAULT_MAX_QUEUE_SIZE)
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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)
|
|
89
|
+
spool_max_bytes: ENV.fetch("ONLYLOGS_SPOOL_MAX_BYTES", Spool::DEFAULT_MAX_BYTES)
|
|
49
90
|
)
|
|
50
|
-
@
|
|
51
|
-
@
|
|
52
|
-
@batch_size = batch_size
|
|
53
|
-
@flush_interval = flush_interval
|
|
54
|
-
@max_queue_size = max_queue_size
|
|
55
|
-
@
|
|
56
|
-
|
|
57
|
-
@
|
|
58
|
-
@
|
|
59
|
-
@
|
|
60
|
-
@
|
|
61
|
-
@
|
|
62
|
-
@
|
|
63
|
-
@
|
|
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
|
-
|
|
73
|
-
|
|
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
|
-
|
|
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
|
-
|
|
88
|
-
|
|
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
|
-
|
|
94
|
-
|
|
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
|
-
|
|
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
|
-
|
|
112
|
-
|
|
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
|
-
|
|
115
|
-
#
|
|
116
|
-
|
|
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
|
-
|
|
119
|
-
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
-
|
|
130
|
-
|
|
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
|
-
|
|
266
|
+
bytes = 0
|
|
267
|
+
deadline = nil
|
|
133
268
|
end
|
|
134
|
-
|
|
135
|
-
|
|
269
|
+
batch << line
|
|
270
|
+
bytes += line.bytesize + 1
|
|
271
|
+
deadline ||= monotonic_now + @flush_interval
|
|
136
272
|
end
|
|
137
273
|
|
|
138
|
-
|
|
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
|
-
|
|
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
|
|
145
|
-
|
|
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
|
-
|
|
168
|
-
|
|
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
|
-
|
|
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
|
|
180
|
-
#
|
|
181
|
-
#
|
|
182
|
-
def
|
|
183
|
-
return
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
240
|
-
#
|
|
241
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
304
|
-
|
|
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
|
|
513
|
+
return unless pause
|
|
312
514
|
|
|
313
515
|
suffix = dropped.positive? ? " (#{dropped} log lines dropped)" : ""
|
|
314
|
-
|
|
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
|
data/lib/onlylogs/http_logger.rb
CHANGED
|
@@ -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.
|
|
13
|
-
#
|
|
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
|