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