onlylogs 0.4.6 → 0.5.2

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e6cd6af6ff916e3ce2fb4ad8d4cf77518b681b86246f7c662390e8e66acadc10
4
- data.tar.gz: 88b5108172130b635097dcad9c1fa55bd377e59f62d8ee0acbffd3b51c203404
3
+ metadata.gz: 43e963f2acccbac4061747abd2606eb298b7ea138404f6e726e03c7a5ffacb4e
4
+ data.tar.gz: c9ee6fb1e854dffddb634be9153d285c8db88836b8a2f7046b63f0a8f0734cfa
5
5
  SHA512:
6
- metadata.gz: 4d4d33d4063e8bdd61e436f92ad5fe1156693003a98bbc6a7debcc8d0a954fcd77f980222f88c0f7181c20b5fd5f2aa073504df37f22a3dfc08159bfba4c0a3a
7
- data.tar.gz: 1eb8bf75d9c8b6f241b08009797d3db0b6c806319b57d69982c85eeb537eb6103904f323664e752f87bbc1ea97938300e11c8912ca951adbf81784ed130bde91
6
+ metadata.gz: 0cf479d9e32a82adb41090a4e4525c4f237e75f7ab52485ac29ce8db5cfaf77fada2434dc675078afabf660ee4d3bf1470f916c13976a3cc11ca5823eb041bbe
7
+ data.tar.gz: 955aa49574feaa833e135a853145858961514ebe4c4a093a3758410b1ad91c5aaa6218f09c4944b29360c0419f9bddd738d4c25b48e99e70b882e95b56b2be60
data/README.md CHANGED
@@ -290,38 +290,93 @@ You are more than welcome to help and contribute to this package.
290
290
 
291
291
  The app uses minitest and includes a dummy app, so getting started should be straightforward.
292
292
 
293
+ ### Getting Started
294
+
295
+ First, install dependencies:
296
+
297
+ ```bash
298
+ bundle install
299
+ ```
300
+
301
+ Then start the dummy Rails app:
302
+
303
+ ```bash
304
+ cd test/dummy
305
+ bundle exec rails s
306
+ ```
307
+
308
+ The dummy app will be available at `http://localhost:3000` and onlylogs at `http://localhost:3000/onlylogs`.
309
+
310
+ ### Generating Test Logs
311
+
312
+ To test onlylogs with live log data, use the continuous log writer script. This is especially useful for testing real-time log streaming and UI behavior.
313
+
314
+ ```bash
315
+ # Generate 1 log entry every 2 seconds (default)
316
+ bin/continuous_log_writer
317
+
318
+ # Generate 5 log entries every 1 second
319
+ bin/continuous_log_writer 5 1
320
+
321
+ # Generate 10 log entries every 3 seconds
322
+ bin/continuous_log_writer 10 3
323
+ ```
324
+
325
+ The script will write logs to `test/dummy/log/development.log`, which will appear in real-time in the onlylogs UI at `http://localhost:3000/onlylogs`.
326
+
327
+ **Example workflow:**
328
+
329
+ ```bash
330
+ # Terminal 1: Start the Rails app
331
+ cd test/dummy && bundle exec rails s
332
+
333
+ # Terminal 2: Generate test logs while viewing in the UI
334
+ bin/continuous_log_writer 3 1
335
+ ```
336
+
337
+ Open `http://localhost:3000/onlylogs` in your browser and watch logs appear as the script writes them.
338
+
293
339
  ### Latency Simulation
294
340
 
295
341
  For testing how onlylogs behaves under production-like network conditions, you can simulate latency for HTTP requests and WebSocket connections using the included latency simulation tool.
296
342
 
297
- ### Usage
343
+ **Parameters:**
344
+ - **Latency** (default: 120ms): The base network delay added to all traffic
345
+ - **Jitter** (default: 30ms): Random variation (±) applied to the latency (changes every 2 seconds to simulate real network conditions)
346
+ - **Port** (default: 3000): The port to apply latency simulation to
347
+
348
+ **Common scenarios:**
298
349
 
299
350
  ```bash
300
- # Enable latency simulation (120±30ms jitter on port 3000)
301
- ./bin/simulate_latency enable
351
+ # Default: 120ms ±30ms jitter (simulates typical 4G/LTE conditions)
352
+ bin/simulate_latency enable
302
353
 
303
- # Enable custom latency simulation (150±30ms jitter on port 3000)
304
- ./bin/simulate_latency enable 150
354
+ # High latency: 500ms (simulates poor connectivity)
355
+ bin/simulate_latency enable 500
305
356
 
306
- # Enable custom latency and jitter (200±50ms jitter on port 3000)
307
- ./bin/simulate_latency enable 200/50
357
+ # High latency with high variation: 300ms ±100ms (simulates unstable connections)
358
+ bin/simulate_latency enable 300/100
308
359
 
309
- # Enable latency simulation on custom port (120±30ms jitter on port 8080)
310
- ./bin/simulate_latency enable -p 8080
360
+ # Custom port: 120ms ±30ms on port 8080
361
+ bin/simulate_latency enable -p 8080
362
+ ```
311
363
 
312
- # Enable custom latency and jitter on custom port (150±50ms jitter on port 8080)
313
- ./bin/simulate_latency enable 150/50 -p 8080
314
364
 
315
- # Test the latency
316
- ./bin/simulate_latency test
365
+ **Testing and monitoring:**
317
366
 
318
- # Check current status
319
- ./bin/simulate_latency status
367
+ ```bash
368
+ # Test the current latency configuration
369
+ bin/simulate_latency test
370
+
371
+ # Check current status and active pipes
372
+ bin/simulate_latency status
320
373
 
321
- # Disable and clean up
322
- ./bin/simulate_latency disable
374
+ # Disable and restore normal network conditions
375
+ bin/simulate_latency disable
323
376
  ```
324
377
 
378
+ The `test` command will run 10 HTTP requests and 10 WebSocket connections, showing you the actual round-trip times and helping you verify the latency is working as expected.
379
+
325
380
  ### Performance Testing
326
381
 
327
382
  Performance tests require large log files that are not included in the repository. You can download them using the provided script:
@@ -10,20 +10,38 @@ module Onlylogs
10
10
 
11
11
  @filter = params[:filter]
12
12
  @autoscroll = params[:autoscroll] != "false"
13
+ @regexp_mode = params[:regexp_mode] == "true"
13
14
  @mode = @filter.blank? ? (params[:mode] || "live") : "search" # "live" or "search"
14
15
  end
15
16
 
17
+ def download
18
+ if params[:log_file_path].blank?
19
+ return render(plain: "Bad Request: missing required parameter 'log_file_path' (encrypted log file path).", status: :bad_request)
20
+ end
21
+
22
+ file_path = authorized_log_file_path(params[:log_file_path])
23
+ send_file file_path, filename: ::File.basename(file_path), disposition: :attachment
24
+ rescue Onlylogs::SecureFilePath::SecurityError
25
+ render plain: "Bad Request: 'log_file_path' could not be decrypted (tampered or malformed token).", status: :bad_request
26
+ rescue Onlylogs::ForbiddenPathError
27
+ render plain: "Forbidden: requested log file path is not in the allowed list.", status: :forbidden
28
+ rescue ActionController::MissingFile, Errno::ENOENT
29
+ render plain: "Not Found: log file is currently unreadable (it may have been rotated, moved, or temporarily unavailable).", status: :not_found
30
+ end
31
+
16
32
  private
17
33
 
18
34
  def selected_log_file_path
19
- encrypted_path = params[:log_file_path]
20
- return default_log_file_path if encrypted_path.blank?
35
+ return default_log_file_path if params[:log_file_path].blank?
36
+ authorized_log_file_path(params[:log_file_path])
37
+ end
21
38
 
39
+ def authorized_log_file_path(encrypted_path)
22
40
  decrypted_path = Onlylogs::SecureFilePath.decrypt(encrypted_path)
23
41
  if Onlylogs.file_path_permitted?(decrypted_path)
24
42
  decrypted_path
25
43
  else
26
- raise SecurityError, "File path not allowed"
44
+ raise Onlylogs::ForbiddenPathError, "File path not allowed"
27
45
  end
28
46
  end
29
47
 
@@ -1,4 +1,7 @@
1
1
  module Onlylogs
2
2
  module ApplicationHelper
3
+ def log_file_label(file)
4
+ Pathname.new(file).relative_path_from(Rails.root).to_s
5
+ end
3
6
  end
4
7
  end
@@ -12,7 +12,7 @@ export default class LogStreamerController extends Controller {
12
12
  regexpMode: { type: Boolean, default: false }
13
13
  };
14
14
 
15
- static targets = ["logLines", "filterInput", "results", "liveMode", "message", "regexpMode", "websocketStatus", "stopButton", "clearButton"];
15
+ static targets = ["logLines", "filterInput", "results", "liveMode", "message", "regexpMode", "websocketStatus", "stopButton", "clearButton", "autoscroll"];
16
16
 
17
17
  connect() {
18
18
  this.consumer = createConsumer();
@@ -86,11 +86,29 @@ export default class LogStreamerController extends Controller {
86
86
 
87
87
  toggleAutoScroll() {
88
88
  this.autoScrollValue = !this.autoScrollValue;
89
+ this.#updateUrlParam('autoscroll', this.autoScrollValue ? null : 'false');
89
90
  this.scroll();
90
91
  }
91
92
 
93
+ pauseForSelection() {
94
+ // Triggered by TextSelectionController#handleMouseDown via text-selection:start event
95
+ // Enter "highlighting mode" - disable both autoscroll and live mode
96
+ if (this.autoScrollValue) {
97
+ this.autoScrollValue = false;
98
+ this.autoscrollTarget.checked = false;
99
+ }
100
+
101
+ if (this.isLiveMode()) {
102
+ this.liveModeTarget.checked = false;
103
+ this.modeValue = 'search';
104
+ this.updateLiveModeState();
105
+ this.stop();
106
+ }
107
+ }
108
+
92
109
  toggleRegexpMode() {
93
110
  this.regexpModeValue = this.regexpModeTarget.checked;
111
+ this.#updateUrlParam('regexp_mode', this.regexpModeValue ? 'true' : null);
94
112
  // If we have a filter applied, reconnect to apply the new regexp mode
95
113
  if (this.filterInputTarget.value && this.filterInputTarget.value.trim() !== '') {
96
114
  this.reconnectWithNewMode();
@@ -100,20 +118,22 @@ export default class LogStreamerController extends Controller {
100
118
  toggleLiveMode() {
101
119
  // this condition looks revered, but the value here has been changed already. so the live mode has been enabled.
102
120
  if (this.isLiveMode()) {
103
- this.clearFilter();
104
121
  this.modeValue = 'live';
105
- this.reconnectWithNewMode();
106
- return;
107
- }
108
- else {
122
+ this.updateLiveModeState();
123
+ if (!this.isRunning) {
124
+ this.start();
125
+ }
126
+ } else {
127
+ // Prevent unchecking - live mode can only be disabled by applying a filter
109
128
  this.liveModeTarget.checked = true;
110
- return false;
111
129
  }
112
130
  }
113
131
 
114
132
  applyFilter() {
133
+ const filterValue = this.filterInputTarget.value;
134
+
115
135
  // If filter is applied, disable live mode
116
- if (this.filterInputTarget.value && this.filterInputTarget.value.trim() !== '') {
136
+ if (filterValue && filterValue.trim() !== '') {
117
137
  this.liveModeTarget.checked = false;
118
138
  this.modeValue = 'search';
119
139
  } else {
@@ -125,6 +145,7 @@ export default class LogStreamerController extends Controller {
125
145
  // Update visual state
126
146
  this.updateLiveModeState();
127
147
  this.updateStopButtonVisibility();
148
+ this.#updateUrlParam('filter', filterValue || null);
128
149
 
129
150
  // Use the global debounced reconnection (300ms delay)
130
151
  this.reconnectWithNewMode();
@@ -168,6 +189,9 @@ export default class LogStreamerController extends Controller {
168
189
  this.updateLiveModeState();
169
190
  this.updateStopButtonVisibility();
170
191
 
192
+ // Update URL with cleared filter
193
+ this.#updateUrlParam('filter');
194
+
171
195
  // Reconnect with cleared filter and live mode
172
196
  this.reconnectWithNewMode();
173
197
  }
@@ -184,11 +208,14 @@ export default class LogStreamerController extends Controller {
184
208
 
185
209
  updateLiveModeState() {
186
210
  const liveModeLabel = this.liveModeTarget.closest('label');
211
+ const hasFilter = this.filterInputTarget.value && this.filterInputTarget.value.trim() !== '';
187
212
 
188
- if (this.isLiveMode()) {
189
- liveModeLabel.classList.remove('live-mode-sticky');
190
- } else {
213
+ if (hasFilter) {
191
214
  liveModeLabel.classList.add('live-mode-sticky');
215
+ this.liveModeTarget.disabled = true;
216
+ } else {
217
+ liveModeLabel.classList.remove('live-mode-sticky');
218
+ this.liveModeTarget.disabled = false;
192
219
  }
193
220
  }
194
221
 
@@ -390,4 +417,17 @@ export default class LogStreamerController extends Controller {
390
417
  this.clusterize = null;
391
418
  this.#initializeClusterize();
392
419
  }
420
+
421
+ #updateUrlParam(param, value = null) {
422
+ const params = new URLSearchParams(window.location.search);
423
+
424
+ if (value != null) {
425
+ params.set(param, value);
426
+ } else {
427
+ params.delete(param);
428
+ }
429
+
430
+ const newUrl = `${window.location.pathname}?${params.toString()}`;
431
+ window.history.replaceState(null, '', newUrl);
432
+ }
393
433
  }
@@ -7,19 +7,26 @@ export default class TextSelectionController extends Controller {
7
7
  this.boundHandleTextSelection = this.handleTextSelection.bind(this)
8
8
  this.boundHandleDocumentClick = this.handleDocumentClick.bind(this)
9
9
  this.boundHandleSelectionChange = this.handleSelectionChange.bind(this)
10
-
10
+ this.boundHandleMouseDown = this.handleMouseDown.bind(this)
11
+
11
12
  // Only listen for text selection on the log lines, not the toolbar
13
+ this.logLinesTarget.addEventListener('mousedown', this.boundHandleMouseDown)
12
14
  this.logLinesTarget.addEventListener('mouseup', this.boundHandleTextSelection)
13
15
  document.addEventListener('click', this.boundHandleDocumentClick)
14
16
  document.addEventListener('selectionchange', this.boundHandleSelectionChange)
15
17
  }
16
18
 
17
19
  disconnect() {
20
+ this.logLinesTarget.removeEventListener('mousedown', this.boundHandleMouseDown)
18
21
  this.logLinesTarget.removeEventListener('mouseup', this.boundHandleTextSelection)
19
22
  document.removeEventListener('click', this.boundHandleDocumentClick)
20
23
  document.removeEventListener('selectionchange', this.boundHandleSelectionChange)
21
24
  }
22
25
 
26
+ handleMouseDown() {
27
+ this.dispatch("start")
28
+ }
29
+
23
30
  handleTextSelection(event) {
24
31
  const selection = window.getSelection()
25
32
  const selectedText = selection.toString().trim()
@@ -5,7 +5,6 @@ module Onlylogs
5
5
  class SecurityError < StandardError; end
6
6
 
7
7
  def self.encrypt(file_path)
8
- encryptor = ActiveSupport::MessageEncryptor.new(encryption_key)
9
8
  encrypted = encryptor.encrypt_and_sign(file_path.to_s)
10
9
  Base64.urlsafe_encode64(encrypted).tr("=", "")
11
10
  rescue => e
@@ -15,15 +14,14 @@ module Onlylogs
15
14
 
16
15
  def self.decrypt(encrypted_path)
17
16
  decoded = Base64.urlsafe_decode64(encrypted_path)
18
- encryptor = ActiveSupport::MessageEncryptor.new(encryption_key)
19
17
  encryptor.decrypt_and_verify(decoded)
20
18
  rescue => e
21
19
  Rails.logger.error "Onlylogs: Decryption failed: #{e.message}"
22
20
  raise SecurityError, "Invalid encrypted file path"
23
21
  end
24
22
 
25
- private_class_method def self.encryption_key
26
- Rails.application.secret_key_base[0..31]
23
+ private_class_method def self.encryptor
24
+ @encryptor ||= ActiveSupport::MessageEncryptor.new(Rails.application.secret_key_base[0..31])
27
25
  end
28
26
  end
29
27
  end
@@ -14,6 +14,10 @@
14
14
  .log-file-dropdown {
15
15
  margin-left: 0.5rem;
16
16
  }
17
+
18
+ .download-link {
19
+ margin-left: 0.5rem;
20
+ }
17
21
  </style>
18
22
  <div class="grid">
19
23
  <div class="grid-header">
@@ -21,15 +25,16 @@
21
25
  <%= form_with url: root_path, method: :get, local: true, class: "log-file-dropdown", data: { controller: "auto-submit" } do %>
22
26
  <select id="log_file_path" name="log_file_path" data-action="change->auto-submit#submit">
23
27
  <% @available_log_files.each do |file| %>
24
- <% encrypted_path = Onlylogs::SecureFilePath.encrypt(file.to_s) %>
25
- <% relative_path = Pathname.new(file).relative_path_from(Rails.root).to_s %>
26
- <option value="<%= encrypted_path %>" <%= "selected" if file.to_s == @log_file_path.to_s %>>
27
- <%= relative_path %>
28
+ <option value="<%= Onlylogs::SecureFilePath.encrypt(file) %>" <%= "selected" if file.to_s == @log_file_path.to_s %>>
29
+ <%= log_file_label(file) %>
28
30
  </option>
29
31
  <% end %>
30
32
  </select>
31
33
  <% end %>
34
+ <% if @log_file_path %>
35
+ <%= link_to "Download", download_log_path(log_file_path: Onlylogs::SecureFilePath.encrypt(@log_file_path)), class: "download-link" %>
36
+ <% end %>
32
37
  <% end %>
33
38
  </div>
34
- <%= render partial: "onlylogs/shared/log_container", locals: { log_file_path: @log_file_path, tail: @max_lines, filter: @filter, autoscroll: @autoscroll } %>
39
+ <%= render partial: "onlylogs/shared/log_container", locals: { log_file_path: @log_file_path, tail: @max_lines, filter: @filter, autoscroll: @autoscroll, regexp_mode: @regexp_mode } %>
35
40
  </div>
@@ -1,4 +1,4 @@
1
- <%# locals: (log_file_path:, tail: 100, filter: "", autoscroll: true) %>
1
+ <%# locals: (log_file_path:, tail: 100, filter: "", autoscroll: true, regexp_mode: false) %>
2
2
  <script nonce="<%= request.content_security_policy_nonce %>"
3
3
  src="https://cdn.jsdelivr.net/npm/clusterize.js@0.18.1/clusterize.min.js"></script>
4
4
  <%= render "onlylogs/shared/log_container_styles" %>
@@ -17,7 +17,9 @@
17
17
  data-log-streamer-cursor-position-value="<%= cursor_position %>"
18
18
  data-log-streamer-filter-value="<%= filter %>"
19
19
  data-log-streamer-auto-scroll-value="<%= autoscroll %>"
20
+ data-log-streamer-regexp-mode-value="<%= regexp_mode %>"
20
21
  data-log-streamer-mode-value="<%= mode %>"
22
+ data-action="text-selection:start->log-streamer#pauseForSelection"
21
23
  class="onlylogs-log-container" >
22
24
  <div data-log-streamer-target="logLines" data-text-selection-target="logLines" id="scrollArea" class="onlylogs-log-lines clusterize-scroll">
23
25
  <div id="contentArea" class="clusterize-content">
@@ -42,13 +44,13 @@
42
44
  </div>
43
45
  <div>
44
46
  <label style="margin-bottom: 0;">
45
- <input id="autoscroll" type="checkbox" <%= autoscroll ? "checked" : "" %> name="autoscroll" data-keyboard-shortcuts-target="autoscroll" data-action="change->log-streamer#toggleAutoScroll">
47
+ <input id="autoscroll" type="checkbox" <%= autoscroll ? "checked" : "" %> name="autoscroll" data-log-streamer-target="autoscroll" data-keyboard-shortcuts-target="autoscroll" data-action="change->log-streamer#toggleAutoScroll">
46
48
  <u>A</u>utoscroll
47
49
  </label>
48
50
  </div>
49
51
  <div>
50
52
  <label style="margin-bottom: 0;">
51
- <input id="regexpMode" type="checkbox" name="regexpMode" data-log-streamer-target="regexpMode" data-text-selection-target="regexpMode" data-action="change->log-streamer#toggleRegexpMode">
53
+ <input id="regexpMode" type="checkbox" <%= regexp_mode ? "checked" : "" %> name="regexpMode" data-log-streamer-target="regexpMode" data-text-selection-target="regexpMode" data-action="change->log-streamer#toggleRegexpMode">
52
54
  <u>R</u>egexp Mode
53
55
  </label>
54
56
  </div>
data/config/routes.rb CHANGED
@@ -1,4 +1,5 @@
1
1
  Onlylogs::Engine.routes.draw do
2
2
  root "logs#index"
3
3
  resources :logs, only: [:index]
4
+ get "download", to: "logs#download", as: :download_log
4
5
  end
@@ -7,24 +7,52 @@ require "uri"
7
7
  # Unlike SocketLogger, it does not require a sidecar process or Puma plugin,
8
8
  # so it works from any process: Puma, GoodJob, Sidekiq, rake tasks, migrations, etc.
9
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).
10
16
  module Onlylogs
11
17
  class HttpLogger < Onlylogs::Logger
12
18
  DEFAULT_BATCH_SIZE = 100
13
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
14
30
 
15
31
  def initialize(
16
32
  local_fallback: $stdout,
17
33
  drain_url: ENV["ONLYLOGS_DRAIN_URL"],
18
34
  batch_size: ENV.fetch("ONLYLOGS_BATCH_SIZE", DEFAULT_BATCH_SIZE).to_i,
19
- flush_interval: ENV.fetch("ONLYLOGS_FLUSH_INTERVAL", DEFAULT_FLUSH_INTERVAL).to_f
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
20
40
  )
21
41
  super(local_fallback)
22
42
  @drain_url = drain_url
23
43
  @batch_size = batch_size
24
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
25
49
  @queue = Queue.new
26
50
  @mutex = Mutex.new
27
51
 
52
+ @consecutive_failures = 0
53
+ @circuit_open_until = nil
54
+ @dropped = 0
55
+
28
56
  if @drain_url
29
57
  start_sender
30
58
  else
@@ -45,7 +73,7 @@ module Onlylogs
45
73
  end
46
74
 
47
75
  formatted = format_message(format_severity(severity), Time.now, progname, message.to_s)
48
- @queue << formatted.chomp if formatted && @drain_url
76
+ enqueue(formatted.chomp) if formatted
49
77
  super
50
78
  end
51
79
 
@@ -62,6 +90,18 @@ module Onlylogs
62
90
 
63
91
  private
64
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
103
+ end
104
+
65
105
  def start_sender
66
106
  @running = true
67
107
 
@@ -102,20 +142,61 @@ module Onlylogs
102
142
 
103
143
  def send_batch(lines)
104
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?
105
148
 
106
149
  uri = URI.parse(@drain_url)
107
150
  http = Net::HTTP.new(uri.host, uri.port)
108
151
  http.use_ssl = (uri.scheme == "https")
109
- http.read_timeout = 5
110
- http.open_timeout = 2
152
+ http.read_timeout = @read_timeout
153
+ http.open_timeout = @open_timeout
111
154
 
112
155
  request = Net::HTTP::Post.new(uri.path)
113
156
  request.body = lines.join("\n")
114
157
  request.content_type = "text/plain"
115
158
 
116
159
  http.start { |h| h.request(request) }
160
+ record_success
117
161
  rescue => e
118
- warn "Onlylogs::HttpLogger error: #{e.class}: #{e.message}"
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}"
119
200
  end
120
201
  end
121
202
  end
@@ -1,3 +1,3 @@
1
1
  module Onlylogs
2
- VERSION = "0.4.6"
2
+ VERSION = "0.5.2"
3
3
  end
data/lib/onlylogs.rb CHANGED
@@ -14,6 +14,8 @@ require "onlylogs/http_logger"
14
14
  # loader.setup
15
15
 
16
16
  module Onlylogs
17
+ class ForbiddenPathError < StandardError; end
18
+
17
19
  if defined?(Importmap)
18
20
  mattr_accessor :importmap, default: Importmap::Map.new
19
21
  end
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.4.6
4
+ version: 0.5.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alessandro Rodi