onlylogs 0.5.2 → 0.7.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/app/assets/config/onlylogs_manifest.js +2 -0
- data/app/assets/javascripts/onlylogs/clusterize.js +342 -0
- data/app/assets/stylesheets/onlylogs/clusterize.css +35 -0
- data/app/channels/onlylogs/logs_channel.rb +110 -32
- data/app/controllers/onlylogs/logs_controller.rb +0 -7
- data/app/javascript/onlylogs/controllers/log_streamer_controller.js +431 -53
- data/app/models/onlylogs/grep.rb +31 -4
- data/app/views/onlylogs/logs/index.html.erb +1 -1
- data/app/views/onlylogs/shared/_log_container.html.erb +44 -21
- data/app/views/onlylogs/shared/_log_container_styles.html.erb +193 -13
- data/app/views/onlylogs/shared/_range_slider.html.erb +47 -0
- data/bin/super_grep +4 -1
- data/bin/super_ripgrep +10 -2
- data/lib/onlylogs/engine.rb +1 -1
- data/lib/onlylogs/http_device.rb +317 -0
- data/lib/onlylogs/http_logger.rb +21 -189
- data/lib/onlylogs/multi_device.rb +30 -0
- data/lib/onlylogs/socket_device.rb +60 -0
- data/lib/onlylogs/socket_logger.rb +17 -63
- data/lib/onlylogs/spool.rb +105 -0
- data/lib/onlylogs/version.rb +1 -1
- data/lib/onlylogs.rb +4 -0
- metadata +8 -1
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "uri"
|
|
5
|
+
require_relative "spool"
|
|
6
|
+
|
|
7
|
+
# A Logger log device that sends log lines to onlylogs.io (or any Vector-compatible sink) directly
|
|
8
|
+
# via HTTP.
|
|
9
|
+
#
|
|
10
|
+
# When the drain is unreachable or unresponsive, we do two things to protect the app:
|
|
11
|
+
# * an upper bound to the in-memory queue: log lines can never accumulate without limit and exhaust memory
|
|
12
|
+
# * cooldown: once the drain is known to be failing we stop attempting
|
|
13
|
+
# requests for a cooldown period instead of blocking on every send for the full
|
|
14
|
+
# read timeout (a down host accepts the TCP/TLS connection but never answers).
|
|
15
|
+
#
|
|
16
|
+
# By default an on-disk Spool buffers any batch we could not deliver and replays it once the
|
|
17
|
+
# drain recovers, so a transient outage or a restart does not lose logs. It is on by default
|
|
18
|
+
# (set ONLYLOGS_SPOOL_DIR empty to disable) and bounded by bytes; see Onlylogs::Spool.
|
|
19
|
+
module Onlylogs
|
|
20
|
+
class HttpDevice
|
|
21
|
+
DEFAULT_BATCH_SIZE = 100
|
|
22
|
+
DEFAULT_FLUSH_INTERVAL = 0.5
|
|
23
|
+
DEFAULT_MAX_QUEUE_SIZE = 10_000
|
|
24
|
+
|
|
25
|
+
# Keep timeouts short: a single slow/dead drain must never stall the app for long.
|
|
26
|
+
DEFAULT_OPEN_TIMEOUT = 0.5
|
|
27
|
+
DEFAULT_READ_TIMEOUT = 0.5
|
|
28
|
+
|
|
29
|
+
# How long Net::HTTP may keep an idle connection around for reuse. Comfortably longer than
|
|
30
|
+
# the default flush interval so normal traffic reuses one connection across many batches.
|
|
31
|
+
DEFAULT_KEEP_ALIVE_TIMEOUT = 30
|
|
32
|
+
|
|
33
|
+
# Open the circuit after this many consecutive failed sends
|
|
34
|
+
CIRCUIT_FAILURE_THRESHOLD = 3
|
|
35
|
+
# ...and keep it open for this long once it is open.
|
|
36
|
+
CIRCUIT_COOLDOWN = 30
|
|
37
|
+
|
|
38
|
+
def initialize(
|
|
39
|
+
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,
|
|
47
|
+
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
|
|
49
|
+
)
|
|
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
|
|
68
|
+
|
|
69
|
+
if @drain_url
|
|
70
|
+
@spool = build_spool(spool_dir, spool_max_bytes)
|
|
71
|
+
start_sender
|
|
72
|
+
else
|
|
73
|
+
$stderr.puts "Onlylogs::HttpDevice: ONLYLOGS_DRAIN_URL is not set; logging locally only." # rubocop:disable Style/StderrPuts
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Receives the already-formatted, already-level-filtered line from Logger#add.
|
|
78
|
+
def write(message)
|
|
79
|
+
return if message.nil? || message.empty?
|
|
80
|
+
# No drain configured: nothing to ship. The local fallback (see MultiDevice) still logs it.
|
|
81
|
+
return unless @drain_url
|
|
82
|
+
|
|
83
|
+
enqueue(message.chomp)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def close
|
|
87
|
+
flush
|
|
88
|
+
@running = false
|
|
89
|
+
@sender_thread&.join(2)
|
|
90
|
+
close_connection
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def flush
|
|
94
|
+
send_batch(drain_queue)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
private
|
|
98
|
+
|
|
99
|
+
# Push a line onto the queue unless it is full. Dropping is intentional: blocking the
|
|
100
|
+
# caller (a request thread) or growing without bound (OOM) are both worse than losing
|
|
101
|
+
# logs while the drain is unavailable.
|
|
102
|
+
def enqueue(line)
|
|
103
|
+
if @queue.size >= @max_queue_size
|
|
104
|
+
@mutex.synchronize { @dropped += 1 }
|
|
105
|
+
return
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
@queue << line
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def start_sender
|
|
112
|
+
@running = true
|
|
113
|
+
|
|
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
|
|
117
|
+
|
|
118
|
+
batch = []
|
|
119
|
+
last_flush = Time.now
|
|
120
|
+
|
|
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
|
|
128
|
+
|
|
129
|
+
if batch.any? && (batch.size >= @batch_size || (Time.now - last_flush) >= @flush_interval)
|
|
130
|
+
send_batch(batch)
|
|
131
|
+
batch = []
|
|
132
|
+
last_flush = Time.now
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
sleep 0.01 if batch.empty?
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
send_batch(batch) if batch.any?
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
at_exit { close }
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def drain_queue
|
|
145
|
+
lines = []
|
|
146
|
+
lines << @queue.pop(true) until @queue.empty?
|
|
147
|
+
lines
|
|
148
|
+
rescue ThreadError
|
|
149
|
+
lines
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def send_batch(lines)
|
|
153
|
+
return if lines.empty?
|
|
154
|
+
|
|
155
|
+
body = lines.join("\n")
|
|
156
|
+
|
|
157
|
+
# Drain is known to be down: skip the request entirely so we don't block for the full read
|
|
158
|
+
# timeout on every batch. Buffer the batch so the cooldown does not cost us data (without a
|
|
159
|
+
# spool configured, spool_write is a no-op and the batch is dropped — best-effort logging).
|
|
160
|
+
if circuit_open?
|
|
161
|
+
spool_write(body)
|
|
162
|
+
return
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
deliver(body)
|
|
166
|
+
record_success
|
|
167
|
+
# The drain just answered: replay anything we had buffered while it was unavailable.
|
|
168
|
+
drain_spool
|
|
169
|
+
rescue => e
|
|
170
|
+
record_failure
|
|
171
|
+
spool_write(body)
|
|
172
|
+
Kernel.warn "Onlylogs::HttpDevice error: #{e.class}: #{e.message}"
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def spool_write(body)
|
|
176
|
+
@spool&.write(body)
|
|
177
|
+
end
|
|
178
|
+
|
|
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
|
|
184
|
+
|
|
185
|
+
@spool.replay do |body|
|
|
186
|
+
deliver(body)
|
|
187
|
+
record_success
|
|
188
|
+
true
|
|
189
|
+
rescue => e
|
|
190
|
+
record_failure
|
|
191
|
+
Kernel.warn "Onlylogs::HttpDevice replay error: #{e.class}: #{e.message}"
|
|
192
|
+
false
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def build_spool(dir, max_bytes)
|
|
197
|
+
return if dir.nil? || dir.to_s.strip.empty?
|
|
198
|
+
|
|
199
|
+
Spool.new(dir: dir, max_bytes: max_bytes)
|
|
200
|
+
rescue => e
|
|
201
|
+
Kernel.warn "Onlylogs::HttpDevice: spool disabled (#{e.class}: #{e.message})"
|
|
202
|
+
nil
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# The spool is on by default. It lives under the app's tmp dir, which survives a drain outage
|
|
206
|
+
# while the app keeps running; point ONLYLOGS_SPOOL_DIR at a persistent volume to also survive
|
|
207
|
+
# redeploys, or set it empty to disable.
|
|
208
|
+
def default_spool_dir
|
|
209
|
+
base = if defined?(Rails) && Rails.respond_to?(:root) && Rails.root
|
|
210
|
+
Rails.root.to_s
|
|
211
|
+
else
|
|
212
|
+
::Dir.pwd
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
::File.join(base, "tmp", "onlylogs", "spool")
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# POST the body over a persistent (kept-alive) connection.
|
|
219
|
+
def deliver(body)
|
|
220
|
+
@http_mutex.synchronize do
|
|
221
|
+
attempts = 0
|
|
222
|
+
response = begin
|
|
223
|
+
attempts += 1
|
|
224
|
+
reused = !@http.nil?
|
|
225
|
+
connection.request(build_request(body))
|
|
226
|
+
rescue
|
|
227
|
+
close_connection
|
|
228
|
+
retry if reused && attempts < 2
|
|
229
|
+
raise
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# Checked outside the rescue on purpose: a non-2xx is an application-level error on a
|
|
233
|
+
# healthy connection, so it must NOT trigger the reconnect-retry above (that would hammer
|
|
234
|
+
# an erroring drain on a perfectly good socket). Raising here records a failure instead.
|
|
235
|
+
ensure_success!(response)
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
|
|
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.
|
|
242
|
+
def ensure_success!(response)
|
|
243
|
+
return if response.is_a?(Net::HTTPSuccess)
|
|
244
|
+
|
|
245
|
+
raise "drain responded #{response.code} #{response.message}"
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def build_request(body)
|
|
249
|
+
# request_uri (not path): it defaults to "/" when the drain URL has no path — Net::HTTP::Post.new("")
|
|
250
|
+
# raises "HTTP request path is empty" — and it carries any query string (e.g. ?token=...) along.
|
|
251
|
+
request = Net::HTTP::Post.new(@uri.request_uri)
|
|
252
|
+
request.body = body
|
|
253
|
+
request.content_type = "text/plain"
|
|
254
|
+
request
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
# Lazily opens and memoizes the connection. Only assigns @http once #start succeeds, so a
|
|
258
|
+
# failed connect leaves @http nil and the next send starts clean. Caller holds @http_mutex.
|
|
259
|
+
def connection
|
|
260
|
+
return @http if @http
|
|
261
|
+
|
|
262
|
+
http = Net::HTTP.new(@uri.host, @uri.port)
|
|
263
|
+
http.use_ssl = (@uri.scheme == "https")
|
|
264
|
+
http.read_timeout = @read_timeout
|
|
265
|
+
http.open_timeout = @open_timeout
|
|
266
|
+
http.keep_alive_timeout = @keep_alive_timeout
|
|
267
|
+
http.start
|
|
268
|
+
@http = http
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
# Caller holds @http_mutex, or no other thread can touch @http (shutdown after the sender
|
|
272
|
+
# thread has joined).
|
|
273
|
+
def close_connection
|
|
274
|
+
@http&.finish
|
|
275
|
+
rescue IOError
|
|
276
|
+
# already closed
|
|
277
|
+
ensure
|
|
278
|
+
@http = nil
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
def circuit_open?
|
|
282
|
+
@mutex.synchronize { !@circuit_open_until.nil? && Time.now < @circuit_open_until }
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
def record_success
|
|
286
|
+
@mutex.synchronize do
|
|
287
|
+
@consecutive_failures = 0
|
|
288
|
+
@circuit_open_until = nil
|
|
289
|
+
end
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
def record_failure
|
|
293
|
+
opened = false
|
|
294
|
+
dropped = 0
|
|
295
|
+
|
|
296
|
+
@mutex.synchronize do
|
|
297
|
+
@consecutive_failures += 1
|
|
298
|
+
next if @consecutive_failures < CIRCUIT_FAILURE_THRESHOLD
|
|
299
|
+
|
|
300
|
+
# (Re)open the circuit. record_failure only runs on a real send attempt — send_batch
|
|
301
|
+
# short-circuits while the circuit is open — so reaching here always means the drain
|
|
302
|
+
# 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
|
|
305
|
+
dropped = @dropped
|
|
306
|
+
@dropped = 0
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
# Warn outside the mutex:
|
|
310
|
+
# doing it inside the lock would re-enter @mutex through record_failure and raise a recursive-lock error.
|
|
311
|
+
return unless opened
|
|
312
|
+
|
|
313
|
+
suffix = dropped.positive? ? " (#{dropped} log lines dropped)" : ""
|
|
314
|
+
Kernel.warn "Onlylogs::HttpDevice: drain unavailable, pausing for #{@circuit_cooldown}s#{suffix}"
|
|
315
|
+
end
|
|
316
|
+
end
|
|
317
|
+
end
|
data/lib/onlylogs/http_logger.rb
CHANGED
|
@@ -1,202 +1,34 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
#
|
|
7
|
-
# Unlike SocketLogger
|
|
8
|
-
#
|
|
9
|
-
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
#
|
|
13
|
-
#
|
|
14
|
-
# requests for a cooldown period instead of blocking on every send for the full
|
|
15
|
-
# read timeout (a down host accepts the TCP/TLS connection but never answers).
|
|
3
|
+
require_relative "http_device"
|
|
4
|
+
require_relative "multi_device"
|
|
5
|
+
|
|
6
|
+
# Logs to $stdout (local fallback) and to onlylogs.io (or any Vector-compatible sink) directly via
|
|
7
|
+
# HTTP. Unlike SocketLogger it does not require a sidecar process or Puma plugin, so it works from
|
|
8
|
+
# any process: Puma, GoodJob, Sidekiq, rake tasks, migrations, etc.
|
|
9
|
+
#
|
|
10
|
+
# This is a plain Onlylogs::Logger whose log device is an Onlylogs::HttpDevice teed with the local
|
|
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.
|
|
16
14
|
module Onlylogs
|
|
17
15
|
class HttpLogger < Onlylogs::Logger
|
|
18
|
-
|
|
19
|
-
DEFAULT_FLUSH_INTERVAL = 0.5
|
|
20
|
-
DEFAULT_MAX_QUEUE_SIZE = 10_000
|
|
21
|
-
|
|
22
|
-
# Keep timeouts short: a single slow/dead drain must never stall the app for long.
|
|
23
|
-
DEFAULT_OPEN_TIMEOUT = 0.5
|
|
24
|
-
DEFAULT_READ_TIMEOUT = 0.5
|
|
25
|
-
|
|
26
|
-
# Open the circuit after this many consecutive failed sends
|
|
27
|
-
CIRCUIT_FAILURE_THRESHOLD = 3
|
|
28
|
-
# ...and keep it open for this long once it is open.
|
|
29
|
-
CIRCUIT_COOLDOWN = 30
|
|
30
|
-
|
|
31
|
-
def initialize(
|
|
32
|
-
local_fallback: $stdout,
|
|
33
|
-
drain_url: ENV["ONLYLOGS_DRAIN_URL"],
|
|
34
|
-
batch_size: ENV.fetch("ONLYLOGS_BATCH_SIZE", DEFAULT_BATCH_SIZE).to_i,
|
|
35
|
-
flush_interval: ENV.fetch("ONLYLOGS_FLUSH_INTERVAL", DEFAULT_FLUSH_INTERVAL).to_f,
|
|
36
|
-
max_queue_size: ENV.fetch("ONLYLOGS_MAX_QUEUE_SIZE", DEFAULT_MAX_QUEUE_SIZE).to_i,
|
|
37
|
-
open_timeout: ENV.fetch("ONLYLOGS_OPEN_TIMEOUT", DEFAULT_OPEN_TIMEOUT).to_f,
|
|
38
|
-
read_timeout: ENV.fetch("ONLYLOGS_READ_TIMEOUT", DEFAULT_READ_TIMEOUT).to_f,
|
|
39
|
-
circuit_cooldown: ENV.fetch("ONLYLOGS_CIRCUIT_COOLDOWN", CIRCUIT_COOLDOWN).to_f
|
|
40
|
-
)
|
|
41
|
-
super(local_fallback)
|
|
42
|
-
@drain_url = drain_url
|
|
43
|
-
@batch_size = batch_size
|
|
44
|
-
@flush_interval = flush_interval
|
|
45
|
-
@max_queue_size = max_queue_size
|
|
46
|
-
@open_timeout = open_timeout
|
|
47
|
-
@read_timeout = read_timeout
|
|
48
|
-
@circuit_cooldown = circuit_cooldown
|
|
49
|
-
@queue = Queue.new
|
|
50
|
-
@mutex = Mutex.new
|
|
51
|
-
|
|
52
|
-
@consecutive_failures = 0
|
|
53
|
-
@circuit_open_until = nil
|
|
54
|
-
@dropped = 0
|
|
16
|
+
attr_reader :device
|
|
55
17
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
$stderr.puts "Onlylogs::HttpLogger error: ONLYLOGS_DRAIN_URL is not set; logger is disabled." # rubocop:disable Style/StderrPuts
|
|
60
|
-
end
|
|
61
|
-
end
|
|
62
|
-
|
|
63
|
-
def add(severity, message = nil, progname = nil, &block)
|
|
64
|
-
return true unless @drain_url
|
|
65
|
-
|
|
66
|
-
if message.nil?
|
|
67
|
-
if block_given?
|
|
68
|
-
message = block.call
|
|
69
|
-
else
|
|
70
|
-
message = progname
|
|
71
|
-
progname = nil
|
|
72
|
-
end
|
|
73
|
-
end
|
|
74
|
-
|
|
75
|
-
formatted = format_message(format_severity(severity), Time.now, progname, message.to_s)
|
|
76
|
-
enqueue(formatted.chomp) if formatted
|
|
77
|
-
super
|
|
78
|
-
end
|
|
79
|
-
|
|
80
|
-
def close
|
|
81
|
-
flush
|
|
82
|
-
@running = false
|
|
83
|
-
@sender_thread&.join(2)
|
|
18
|
+
def initialize(local_fallback: $stdout, **device_options)
|
|
19
|
+
@device = HttpDevice.new(**device_options)
|
|
20
|
+
super(MultiDevice.new(local_fallback, @device))
|
|
84
21
|
end
|
|
85
22
|
|
|
23
|
+
# Drain the device's in-memory queue to the drain now (tests and graceful shutdown rely on it).
|
|
86
24
|
def flush
|
|
87
|
-
|
|
88
|
-
super
|
|
89
|
-
end
|
|
90
|
-
|
|
91
|
-
private
|
|
92
|
-
|
|
93
|
-
# Push a line onto the queue unless it is full. Dropping is intentional: blocking the
|
|
94
|
-
# caller (a request thread) or growing without bound (OOM) are both worse than losing
|
|
95
|
-
# logs while the drain is unavailable.
|
|
96
|
-
def enqueue(line)
|
|
97
|
-
if @queue.size >= @max_queue_size
|
|
98
|
-
@mutex.synchronize { @dropped += 1 }
|
|
99
|
-
return
|
|
100
|
-
end
|
|
101
|
-
|
|
102
|
-
@queue << line
|
|
25
|
+
@device.flush
|
|
103
26
|
end
|
|
104
27
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
@
|
|
109
|
-
batch = []
|
|
110
|
-
last_flush = Time.now
|
|
111
|
-
|
|
112
|
-
while @running || !@queue.empty?
|
|
113
|
-
begin
|
|
114
|
-
line = @queue.pop(true)
|
|
115
|
-
batch << line if line
|
|
116
|
-
rescue ThreadError
|
|
117
|
-
# queue empty
|
|
118
|
-
end
|
|
119
|
-
|
|
120
|
-
if batch.any? && (batch.size >= @batch_size || (Time.now - last_flush) >= @flush_interval)
|
|
121
|
-
send_batch(batch)
|
|
122
|
-
batch = []
|
|
123
|
-
last_flush = Time.now
|
|
124
|
-
end
|
|
125
|
-
|
|
126
|
-
sleep 0.01 if batch.empty?
|
|
127
|
-
end
|
|
128
|
-
|
|
129
|
-
send_batch(batch) if batch.any?
|
|
130
|
-
end
|
|
131
|
-
|
|
132
|
-
at_exit { close }
|
|
133
|
-
end
|
|
134
|
-
|
|
135
|
-
def drain_queue
|
|
136
|
-
lines = []
|
|
137
|
-
lines << @queue.pop(true) until @queue.empty?
|
|
138
|
-
lines
|
|
139
|
-
rescue ThreadError
|
|
140
|
-
lines
|
|
141
|
-
end
|
|
142
|
-
|
|
143
|
-
def send_batch(lines)
|
|
144
|
-
return if lines.empty?
|
|
145
|
-
# Drain is known to be down: skip the request entirely so we don't block for the
|
|
146
|
-
# full read timeout on every batch. The lines are dropped (best-effort logging).
|
|
147
|
-
return if circuit_open?
|
|
148
|
-
|
|
149
|
-
uri = URI.parse(@drain_url)
|
|
150
|
-
http = Net::HTTP.new(uri.host, uri.port)
|
|
151
|
-
http.use_ssl = (uri.scheme == "https")
|
|
152
|
-
http.read_timeout = @read_timeout
|
|
153
|
-
http.open_timeout = @open_timeout
|
|
154
|
-
|
|
155
|
-
request = Net::HTTP::Post.new(uri.path)
|
|
156
|
-
request.body = lines.join("\n")
|
|
157
|
-
request.content_type = "text/plain"
|
|
158
|
-
|
|
159
|
-
http.start { |h| h.request(request) }
|
|
160
|
-
record_success
|
|
161
|
-
rescue => e
|
|
162
|
-
record_failure
|
|
163
|
-
Kernel.warn "Onlylogs::HttpLogger error: #{e.class}: #{e.message}"
|
|
164
|
-
end
|
|
165
|
-
|
|
166
|
-
def circuit_open?
|
|
167
|
-
@mutex.synchronize { !@circuit_open_until.nil? && Time.now < @circuit_open_until }
|
|
168
|
-
end
|
|
169
|
-
|
|
170
|
-
def record_success
|
|
171
|
-
@mutex.synchronize do
|
|
172
|
-
@consecutive_failures = 0
|
|
173
|
-
@circuit_open_until = nil
|
|
174
|
-
end
|
|
175
|
-
end
|
|
176
|
-
|
|
177
|
-
def record_failure
|
|
178
|
-
opened = false
|
|
179
|
-
dropped = 0
|
|
180
|
-
|
|
181
|
-
@mutex.synchronize do
|
|
182
|
-
@consecutive_failures += 1
|
|
183
|
-
next if @consecutive_failures < CIRCUIT_FAILURE_THRESHOLD
|
|
184
|
-
|
|
185
|
-
# (Re)open the circuit. record_failure only runs on a real send attempt — send_batch
|
|
186
|
-
# short-circuits while the circuit is open — so reaching here always means the drain
|
|
187
|
-
# is still down and we should pause again (this is how recovery retries every cooldown).
|
|
188
|
-
@circuit_open_until = Time.now + @circuit_cooldown
|
|
189
|
-
opened = true
|
|
190
|
-
dropped = @dropped
|
|
191
|
-
@dropped = 0
|
|
192
|
-
end
|
|
193
|
-
|
|
194
|
-
# Warn outside the mutex:
|
|
195
|
-
# doing it inside the lock would re-enter @mutex through add -> enqueue and raise a recursive-lock error.
|
|
196
|
-
return unless opened
|
|
197
|
-
|
|
198
|
-
suffix = dropped.positive? ? " (#{dropped} log lines dropped)" : ""
|
|
199
|
-
Kernel.warn "Onlylogs::HttpLogger: drain unavailable, pausing for #{@circuit_cooldown}s#{suffix}"
|
|
28
|
+
# Only the remote device is ours to close; the local fallback ($stdout) belongs to the app, so
|
|
29
|
+
# we deliberately do not call super (which would close the whole log device, fallback included).
|
|
30
|
+
def close
|
|
31
|
+
@device.close
|
|
200
32
|
end
|
|
201
33
|
end
|
|
202
34
|
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Onlylogs
|
|
4
|
+
# A Logger log device that fans each line out to several underlying devices, e.g. the local
|
|
5
|
+
# $stdout fallback plus a remote sink (Onlylogs::SocketDevice / Onlylogs::HttpDevice).
|
|
6
|
+
class MultiDevice
|
|
7
|
+
def initialize(*devices)
|
|
8
|
+
@devices = devices.compact
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def write(message)
|
|
12
|
+
return if message.nil? || message.empty?
|
|
13
|
+
|
|
14
|
+
@devices.each { |device| device.write(message) }
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def close
|
|
18
|
+
@devices.each do |device|
|
|
19
|
+
# Never close the process' standard streams — the app owns them, not us.
|
|
20
|
+
next if device.equal?($stdout) || device.equal?($stderr)
|
|
21
|
+
|
|
22
|
+
device.close if device.respond_to?(:close)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def flush
|
|
27
|
+
@devices.each { |device| device.flush if device.respond_to?(:flush) }
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "socket"
|
|
4
|
+
|
|
5
|
+
module Onlylogs
|
|
6
|
+
# Send failures are reported to $stderr — never through a logger — so a failing socket can never
|
|
7
|
+
# re-enter logging and deadlock or loop.
|
|
8
|
+
class SocketDevice
|
|
9
|
+
DEFAULT_SOCKET = "tmp/sockets/onlylogs-sidecar.sock"
|
|
10
|
+
|
|
11
|
+
def initialize(socket_path: ENV.fetch("ONLYLOGS_SIDECAR_SOCKET", DEFAULT_SOCKET))
|
|
12
|
+
@socket_path = socket_path
|
|
13
|
+
@socket_mutex = Mutex.new
|
|
14
|
+
@socket = nil
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def write(message)
|
|
18
|
+
return if message.nil? || message.empty?
|
|
19
|
+
|
|
20
|
+
socket = ensure_socket
|
|
21
|
+
socket&.puts(message)
|
|
22
|
+
rescue Errno::EPIPE, Errno::ECONNREFUSED, Errno::ENOENT => e
|
|
23
|
+
$stderr.puts "Onlylogs::SocketDevice error: #{e.message}" # rubocop:disable Style/StderrPuts
|
|
24
|
+
reconnect_socket
|
|
25
|
+
rescue => e
|
|
26
|
+
$stderr.puts "Onlylogs::SocketDevice unexpected error: #{e.class}: #{e.message}" # rubocop:disable Style/StderrPuts
|
|
27
|
+
reconnect_socket
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def close
|
|
31
|
+
reconnect_socket
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def ensure_socket
|
|
37
|
+
return @socket if @socket
|
|
38
|
+
|
|
39
|
+
@socket_mutex.synchronize do
|
|
40
|
+
@socket ||= UNIXSocket.new(@socket_path)
|
|
41
|
+
rescue => e
|
|
42
|
+
$stderr.puts "Unable to connect to Onlylogs sidecar (#{@socket_path}): #{e.message}" # rubocop:disable Style/StderrPuts
|
|
43
|
+
@socket = nil
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
@socket
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def reconnect_socket
|
|
50
|
+
@socket_mutex.synchronize do
|
|
51
|
+
begin
|
|
52
|
+
@socket&.close
|
|
53
|
+
rescue
|
|
54
|
+
nil
|
|
55
|
+
end
|
|
56
|
+
@socket = nil
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|