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.
@@ -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
@@ -1,202 +1,34 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "net/http"
4
- require "uri"
5
-
6
- # This logger sends messages to onlylogs.io (or any Vector-compatible sink) directly via HTTP.
7
- # Unlike SocketLogger, it does not require a sidecar process or Puma plugin,
8
- # so it works from any process: Puma, GoodJob, Sidekiq, rake tasks, migrations, etc.
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
12
- # exhaust memory
13
- # * cooldown: once the drain is known to be failing we stop attempting
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
- DEFAULT_BATCH_SIZE = 100
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
- if @drain_url
57
- start_sender
58
- else
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
- send_batch(drain_queue)
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
- def start_sender
106
- @running = true
107
-
108
- @sender_thread = Thread.new do
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