onlylogs 0.8.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0e6b4ee2fde339a248ff9e15557c8bb8f0ea525b3807aa09b157e537057c0c7a
4
- data.tar.gz: 208543eb460dc3f7c302fac4d4bd3277c710e402346f759c7e66c1b2dfc7049b
3
+ metadata.gz: 9303c9ba4c727343d48702332baef85d112068f0639a148ff42afc8693b1f7af
4
+ data.tar.gz: 82fe3f9d70cfc3b2d4a6f661a296974037aca9fd8bd8e97317476bb091d3c320
5
5
  SHA512:
6
- metadata.gz: 4b47a580f758cacca9c34b1de0ebe445ff4db5df1e45f6e515cd80b1dd866d3dbbb39d7aea7862b94ee46e5d18cd39797fbadcb202e59745bcb94fb0a62da2b7
7
- data.tar.gz: 8956f328a31a63cbf07a180a51ffa612c516f51ae37523bba49f6a41557a14ae35a83d91bc1bcc05b465bedd6cbc61cbffb8a402822a27b17d534668acd65211
6
+ metadata.gz: d7bf3497e6013d367776e50e716d113781cdde78b79d302b01a126778092aa36fb0f025c93162301b4f9444d9be83bd30ebd2b832d3e3e5da29d6ed721bd6138
7
+ data.tar.gz: 879705e6eb4dafe29e1bf1698495cfb0731ccefc13f3127547a45173e4fdc6beda4ea53c13b4406ae6c3d1ba10ffe36f3b2f21bf1a7e3ff17adf195b5161170f
data/README.md CHANGED
@@ -235,7 +235,7 @@ end
235
235
 
236
236
  Onlylogs automatically detects file paths in log messages and converts them into clickable links that open in your preferred code editor.
237
237
 
238
- For a complete list of supported editors, see [lib/onlylogs/editor_detector.rb](lib/onlylogs/editor_detector.rb).
238
+ For a complete list of supported editors, see [app/models/onlylogs/file_path_parser.rb](app/models/onlylogs/file_path_parser.rb).
239
239
 
240
240
  ```bash
241
241
  # env variables
@@ -272,6 +272,22 @@ Onlylogs.configure do |config|
272
272
  end
273
273
  ```
274
274
 
275
+ #### Bounding How Long a Viewer Search Can Run
276
+
277
+ Searches started from the log viewer stop after `search_timeout` seconds, keeping whatever they
278
+ found and reporting that they did not reach the end of the file. The default is 120 seconds:
279
+
280
+ ```ruby
281
+ # config/initializers/onlylogs.rb
282
+ Onlylogs.configure do |config|
283
+ config.search_timeout = 300
284
+
285
+ # Or remove the ceiling entirely (a search then holds a CPU until it reaches
286
+ # the end of the file, however large it is)
287
+ config.search_timeout = nil
288
+ end
289
+ ```
290
+
275
291
  ### Filtering Log Lines with a Denylist
276
292
 
277
293
  The `Onlylogs::Formatter` supports a denylist: an array of regular expressions that prevents matching lines from being logged. This is useful for filtering out noisy or irrelevant entries like health checks or asset requests.
@@ -324,6 +340,16 @@ bin/continuous_log_writer 10 3
324
340
 
325
341
  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
342
 
343
+ The lines are generated by `Onlylogs::ContinuousLogWriter`, in the same format a Rails app logging through `Onlylogs::Logger` produces. The script is a thin wrapper, so applications embedding onlylogs can reuse the writer against any file of their own:
344
+
345
+ ```ruby
346
+ require "onlylogs/continuous_log_writer"
347
+
348
+ Onlylogs::ContinuousLogWriter.new(some_log_path, logs_per_batch: 3, interval: 1).call
349
+ ```
350
+
351
+ It is plain Ruby (it does not need Rails to be booted) and is not loaded by `require "onlylogs"` — require it explicitly where you need it.
352
+
327
353
  **Example workflow:**
328
354
 
329
355
  ```bash
@@ -16,27 +16,8 @@ module Onlylogs
16
16
  @last_initialize_params = data.dup
17
17
  cleanup_existing_operations
18
18
 
19
- # Decrypt and verify the file path
20
- begin
21
- encrypted_file_path = data["file_path"]
22
- if encrypted_file_path.present?
23
- file_path = Onlylogs::SecureFilePath.decrypt(encrypted_file_path)
24
-
25
- # Verify the decrypted path is still allowed
26
- unless Onlylogs.file_path_permitted?(file_path)
27
- Rails.logger.error "Onlylogs: Attempted to access non-allowed file: #{file_path}"
28
- transmit({action: "error", content: "Access denied"})
29
- return
30
- end
31
- else
32
- # Fallback to default if no encrypted path provided
33
- file_path = Onlylogs.default_log_file_path
34
- end
35
- rescue Onlylogs::SecureFilePath::SecurityError => e
36
- Rails.logger.error "Onlylogs: Security violation - #{e.message}"
37
- transmit({action: "error", content: "Access denied"})
38
- return
39
- end
19
+ file_path = authorized_file_path(data["file_path"])
20
+ return unless file_path
40
21
 
41
22
  # Check if the file is a text file
42
23
  unless Onlylogs::File.text_file?(file_path)
@@ -72,6 +53,32 @@ module Onlylogs
72
53
 
73
54
  private
74
55
 
56
+ # Decrypts and authorises the requested file. Returns nil (after transmitting
57
+ # an error) when there is no token, when it cannot be decrypted, or when the
58
+ # decrypted path is not on the configured allow-list — the three ways a client
59
+ # can ask for something it is not entitled to stream.
60
+ def authorized_file_path(encrypted_file_path)
61
+ if encrypted_file_path.blank?
62
+ Rails.logger.error "Onlylogs: initialize_watcher without a file path token; refusing to stream"
63
+ transmit({action: "error", content: "Access denied"})
64
+ return nil
65
+ end
66
+
67
+ file_path = Onlylogs::SecureFilePath.decrypt(encrypted_file_path)
68
+
69
+ unless Onlylogs.file_path_permitted?(file_path)
70
+ Rails.logger.error "Onlylogs: Attempted to access non-allowed file: #{file_path}"
71
+ transmit({action: "error", content: "Access denied"})
72
+ return nil
73
+ end
74
+
75
+ file_path
76
+ rescue Onlylogs::SecureFilePath::SecurityError => e
77
+ Rails.logger.error "Onlylogs: Security violation - #{e.message}"
78
+ transmit({action: "error", content: "Access denied"})
79
+ nil
80
+ end
81
+
75
82
  def cleanup_existing_operations
76
83
  if @batch_sender
77
84
  @batch_sender.stop(send_remaining_lines: false)
@@ -168,55 +175,18 @@ module Onlylogs
168
175
  last_byte_offset = nil
169
176
  line_count = 0
170
177
 
171
- Rails.logger.silence(Logger::ERROR) do
172
- skip_first = start_position > 0
173
-
174
- if filter.present?
175
- # Use grep for filtered search
176
- @log_file.grep(filter, regexp_mode: regexp_mode, start_position: start_position, end_position: end_position) do |result|
177
- break if @batch_sender.nil? || @log_watcher_running == false
178
-
179
- # Skip first line if start_position > 0 (line is cut off at byte boundary)
180
- if skip_first
181
- skip_first = false
182
- next
183
- end
184
-
185
- # Result is a hash with {byte_offset, content}
186
- byte_offset = result[:byte_offset]
187
- log_line = result[:content]
178
+ show_expand_button = filter.present?
188
179
 
189
- # Buffer previous line and skip it to avoid cut-off lines at boundaries
190
- if last_line
191
- @batch_sender.add_line(render_log_line(last_line, byte_offset: last_byte_offset, show_expand_button: true))
192
- line_count += 1
193
- end
194
- last_line = log_line
195
- last_byte_offset = byte_offset
196
- end
197
- else
198
- # No filter - read all lines directly (skip grep)
199
- # Still need byte_offset for highlighting when expanding around a line
200
- current_byte_offset = start_position
201
- read_byte_range(file_path, start_position, end_position) do |log_line|
202
- break if @batch_sender.nil? || @log_watcher_running == false
203
-
204
- # Skip first line if start_position > 0 (line is cut off at byte boundary)
205
- if skip_first
206
- skip_first = false
207
- next
208
- end
209
-
210
- # Buffer previous line and skip it to avoid cut-off lines at boundaries
211
- if last_line
212
- @batch_sender.add_line(render_log_line(last_line, byte_offset: last_byte_offset))
213
- line_count += 1
214
- end
215
- last_line = log_line
216
- last_byte_offset = current_byte_offset
217
- # Account for line content plus newline character (2 bytes for \r\n or 1 for \n)
218
- current_byte_offset += log_line.bytesize + 1
180
+ Rails.logger.silence(Logger::ERROR) do
181
+ each_matching_line(file_path, filter, regexp_mode, start_position, end_position) do |log_line, byte_offset|
182
+ # Buffer previous line and skip it to avoid cut-off lines at boundaries
183
+ if last_line
184
+ @batch_sender.add_line(render_log_line(last_line, byte_offset: last_byte_offset,
185
+ show_expand_button: show_expand_button))
186
+ line_count += 1
219
187
  end
188
+ last_line = log_line
189
+ last_byte_offset = byte_offset
220
190
  end
221
191
  end
222
192
 
@@ -234,6 +204,11 @@ module Onlylogs
234
204
  else
235
205
  transmit({action: "finish", content: "Search finished."})
236
206
  end
207
+ rescue Onlylogs::Grep::TimeoutError
208
+ @batch_sender&.stop
209
+ transmit({action: "finish",
210
+ content: "Search stopped after #{Onlylogs.search_timeout} seconds and did not reach " \
211
+ "the end of the file. Narrow the range or the filter and search again."})
237
212
  ensure
238
213
  # Always cleanup even if interrupted or error occurs
239
214
  @batch_sender&.stop
@@ -243,6 +218,49 @@ module Onlylogs
243
218
  end
244
219
  end
245
220
 
221
+ # Yields [line, byte_offset] over the requested slice: through grep when there
222
+ # is a filter, straight off disk when there is not. Either way the offsets are
223
+ # the real ones, so "show around this line" can re-centre on them.
224
+ #
225
+ # A range that starts mid-line opens with a fragment of a line, which is
226
+ # dropped - but its bytes still count towards every offset after it.
227
+ def each_matching_line(file_path, filter, regexp_mode, start_position, end_position)
228
+ skip_first = start_position > 0
229
+
230
+ if filter.present?
231
+ @log_file.grep(filter, regexp_mode: regexp_mode, start_position: start_position,
232
+ end_position: end_position, timeout: Onlylogs.search_timeout) do |result|
233
+ break if reading_stopped?
234
+
235
+ if skip_first
236
+ skip_first = false
237
+ next
238
+ end
239
+
240
+ yield result[:content], result[:byte_offset]
241
+ end
242
+ else
243
+ offset = start_position
244
+
245
+ read_byte_range(file_path, start_position, end_position) do |log_line, raw_bytesize|
246
+ break if reading_stopped?
247
+
248
+ if skip_first
249
+ skip_first = false
250
+ offset += raw_bytesize
251
+ next
252
+ end
253
+
254
+ yield log_line, offset
255
+ offset += raw_bytesize
256
+ end
257
+ end
258
+ end
259
+
260
+ def reading_stopped?
261
+ @batch_sender.nil? || @log_watcher_running == false
262
+ end
263
+
246
264
  def read_byte_range(file_path, start_position, end_position)
247
265
  file_size = ::File.size(file_path)
248
266
  range_size = (end_position || file_size) - start_position
@@ -250,7 +268,7 @@ module Onlylogs
250
268
  return if start_position < 0 || range_size <= 0 || start_position >= file_size
251
269
 
252
270
  ::File.read(file_path, range_size, start_position).each_line do |line|
253
- yield line.chomp
271
+ yield line.chomp, line.bytesize
254
272
  end
255
273
  rescue => e
256
274
  Rails.logger.error "Error reading byte range: #{e.message}"
@@ -1,7 +1,7 @@
1
1
  import { Controller } from "@hotwired/stimulus"
2
2
 
3
3
  export default class KeyboardShortcutsController extends Controller {
4
- static targets = ["liveMode", "autoscroll"]
4
+ static targets = ["liveButton", "searchButton", "autoscroll"]
5
5
 
6
6
  connect() {
7
7
  this.boundHandleKeydown = this.handleKeydown.bind(this)
@@ -13,15 +13,25 @@ export default class KeyboardShortcutsController extends Controller {
13
13
  }
14
14
 
15
15
  handleKeydown(event) {
16
+ // These are bare single-key shortcuts. Any modifier means the keystroke belongs
17
+ // to the browser or the OS (cmd+L, cmd+A, cmd+S) and must pass straight through.
18
+ if (event.metaKey || event.ctrlKey || event.altKey) {
19
+ return
20
+ }
21
+
16
22
  // Only handle shortcuts when not typing in input fields
17
- if (event.target.tagName === 'INPUT' || event.target.tagName === 'TEXTAREA') {
23
+ if (event.target.tagName === 'INPUT' || event.target.tagName === 'TEXTAREA' || event.target.isContentEditable) {
18
24
  return
19
25
  }
20
26
 
21
27
  switch (event.key.toLowerCase()) {
22
28
  case 'l':
23
29
  event.preventDefault()
24
- this.toggleLiveMode()
30
+ if (this.hasLiveButtonTarget) this.liveButtonTarget.click()
31
+ break
32
+ case 's':
33
+ event.preventDefault()
34
+ if (this.hasSearchButtonTarget) this.searchButtonTarget.click()
25
35
  break
26
36
  case 'a':
27
37
  event.preventDefault()
@@ -30,15 +40,9 @@ export default class KeyboardShortcutsController extends Controller {
30
40
  }
31
41
  }
32
42
 
33
- toggleLiveMode() {
34
- if (this.hasLiveModeTarget) {
35
- this.liveModeTarget.checked = !this.liveModeTarget.checked
36
- this.liveModeTarget.dispatchEvent(new Event('change', { bubbles: true }))
37
- }
38
- }
39
-
40
43
  toggleAutoscroll() {
41
- if (this.hasAutoscrollTarget) {
44
+ // Autoscroll only exists while live; ignore the shortcut when it is not on screen.
45
+ if (this.hasAutoscrollTarget && this.autoscrollTarget.offsetParent !== null) {
42
46
  this.autoscrollTarget.checked = !this.autoscrollTarget.checked
43
47
  this.autoscrollTarget.dispatchEvent(new Event('change', { bubbles: true }))
44
48
  }