onlylogs 0.9.0 → 0.11.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: f166264d89c5d2273751e3377ef236168da610b1d37d5ddf02d33959f2fe318d
4
- data.tar.gz: a612d6738ee600171cd21a78b403d1abef06fdf5df893ebcc8c3401b092f6a51
3
+ metadata.gz: 4707246899a958c58bf6ea58aba6f21234d938c660ce23dbad8df598654a4b5c
4
+ data.tar.gz: c46cf3df22e80cd90749bf144f62a271a9556b13ee718640377aa86f34608e05
5
5
  SHA512:
6
- metadata.gz: 6c2d38d16486ea48cd8e6c695e1b9234dbe8e300f45342afa163adfef2e532cdf1cf0cdecdca411c9a13a80c6c4f32aa5c33a82dfc9137c2da35f72cffa0f2c6
7
- data.tar.gz: d4b8b016e5c089712e288d6533b613571f5edee524b2784a3aff80b4d8701913aabb169f72cdedb4220c01a86f3be33d0c52eedc644538db1a3fe3e259e947c9
6
+ metadata.gz: 7eabe1b6124704eee89f759001cb7df9bf8fa5bb71ad98ac9c26bbc091db38c93ee3c7fdb4b35729f33cc9eeb2d0ac3170b2d2ef11ae5b979b7ec454664bc435
7
+ data.tar.gz: fa8226aa140b44b8eac0f3a03000aac95615470343d90de827d98da79ba4045c776b052171b3555ef955db2747dc760640cd787f6207ae95f6e67e70e152dca1
data/README.md CHANGED
@@ -58,6 +58,33 @@ Here you can grep your logs with regular expressions.
58
58
  > If ripgrep is not installed, onlylogs falls back to `grep`.
59
59
  > A warning icon (⚠️) will be displayed in the toolbar when using `grep` to indicate slower search performance.
60
60
 
61
+ ## Logging in production
62
+
63
+ Rails apps log to `STDOUT` in production and let the platform collect the stream.
64
+ onlylogs keeps that default: `Onlylogs::HttpLogger.new` writes every line to `$stdout` and, when
65
+ `ONLYLOGS_DRAIN_URL` is set, ships it to https://onlylogs.io as well. Nothing is written to disk.
66
+
67
+ ```ruby
68
+ # config/environments/production.rb
69
+ config.logger = Onlylogs::HttpLogger.new
70
+ ```
71
+
72
+ Keep this default on an **ephemeral filesystem** (Heroku, Deploio, Docker, Kubernetes). A log file
73
+ there is lost on every deploy and, while it grows, its page cache counts towards the container's
74
+ memory limit: it shows up as a memory leak that resets on each release.
75
+
76
+ If you have a **persistent disk** and want the files, for instance to browse them with the engine,
77
+ pass a rotating log device as the local fallback. Rails rotates at 100 MB in development, which is a
78
+ good size here too:
79
+
80
+ ```ruby
81
+ # config/environments/production.rb
82
+ log_file = Logger::LogDevice.new(Rails.root.join("log", "production.log"), shift_age: 5, shift_size: 100.megabytes)
83
+ config.logger = Onlylogs::HttpLogger.new(local_fallback: log_file)
84
+ ```
85
+
86
+ Any IO or log device works and is used as is. `Onlylogs::SocketLogger` accepts the same argument.
87
+
61
88
  ## Authentication
62
89
 
63
90
  Yes, we should do this right away, because this engine gives access to your log files, so you want to be sure.
@@ -235,7 +262,7 @@ end
235
262
 
236
263
  Onlylogs automatically detects file paths in log messages and converts them into clickable links that open in your preferred code editor.
237
264
 
238
- For a complete list of supported editors, see [lib/onlylogs/editor_detector.rb](lib/onlylogs/editor_detector.rb).
265
+ For a complete list of supported editors, see [app/models/onlylogs/file_path_parser.rb](app/models/onlylogs/file_path_parser.rb).
239
266
 
240
267
  ```bash
241
268
  # env variables
@@ -294,7 +321,7 @@ The `Onlylogs::Formatter` supports a denylist: an array of regular expressions t
294
321
 
295
322
  ```ruby
296
323
  # config/environments/production.rb
297
- config.logger = Onlylogs::Logger.new(Rails.root.join("log", "production.log"))
324
+ config.logger = Onlylogs::HttpLogger.new
298
325
  config.logger.formatter.denylist = [/health_check/, /ping/, /\.css\z/]
299
326
  ```
300
327
 
@@ -340,6 +367,16 @@ bin/continuous_log_writer 10 3
340
367
 
341
368
  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`.
342
369
 
370
+ 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:
371
+
372
+ ```ruby
373
+ require "onlylogs/continuous_log_writer"
374
+
375
+ Onlylogs::ContinuousLogWriter.new(some_log_path, logs_per_batch: 3, interval: 1).call
376
+ ```
377
+
378
+ 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.
379
+
343
380
  **Example workflow:**
344
381
 
345
382
  ```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)
@@ -85,6 +92,10 @@ module Onlylogs
85
92
  # an explicit cursor (matches the default whole-file live-mode page load).
86
93
  LIVE_TAIL_BYTES = 10_000
87
94
 
95
+ # The button re-runs the viewer's own reset, which reopens the file at its current tail.
96
+ ROTATED_MESSAGE = 'The file was rotated. <button type="button" class="reload-button" ' \
97
+ 'data-action="click->log-streamer#reset">Reload</button>'
98
+
88
99
  def start_log_watcher(file_path, filter = nil, regexp_mode = false)
89
100
  return if @log_watcher_running
90
101
 
@@ -123,6 +134,10 @@ module Onlylogs
123
134
  })
124
135
  end
125
136
  end
137
+
138
+ # watch returns on its own only when the file was rotated: nothing more will
139
+ # arrive under this name from where we are, so the viewer has to start over.
140
+ transmit({action: "finish", content: ROTATED_MESSAGE}) if @log_watcher_running
126
141
  end
127
142
  rescue => e
128
143
  Rails.logger.error "Log watcher error: #{e.message}"
@@ -168,56 +183,18 @@ module Onlylogs
168
183
  last_byte_offset = nil
169
184
  line_count = 0
170
185
 
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,
177
- end_position: end_position, timeout: Onlylogs.search_timeout) do |result|
178
- break if @batch_sender.nil? || @log_watcher_running == false
179
-
180
- # Skip first line if start_position > 0 (line is cut off at byte boundary)
181
- if skip_first
182
- skip_first = false
183
- next
184
- end
186
+ show_expand_button = filter.present?
185
187
 
186
- # Result is a hash with {byte_offset, content}
187
- byte_offset = result[:byte_offset]
188
- log_line = result[:content]
189
-
190
- # Buffer previous line and skip it to avoid cut-off lines at boundaries
191
- if last_line
192
- @batch_sender.add_line(render_log_line(last_line, byte_offset: last_byte_offset, show_expand_button: true))
193
- line_count += 1
194
- end
195
- last_line = log_line
196
- last_byte_offset = byte_offset
197
- end
198
- else
199
- # No filter - read all lines directly (skip grep)
200
- # Still need byte_offset for highlighting when expanding around a line
201
- current_byte_offset = start_position
202
- read_byte_range(file_path, start_position, end_position) do |log_line|
203
- break if @batch_sender.nil? || @log_watcher_running == false
204
-
205
- # Skip first line if start_position > 0 (line is cut off at byte boundary)
206
- if skip_first
207
- skip_first = false
208
- next
209
- end
210
-
211
- # Buffer previous line and skip it to avoid cut-off lines at boundaries
212
- if last_line
213
- @batch_sender.add_line(render_log_line(last_line, byte_offset: last_byte_offset))
214
- line_count += 1
215
- end
216
- last_line = log_line
217
- last_byte_offset = current_byte_offset
218
- # Account for line content plus newline character (2 bytes for \r\n or 1 for \n)
219
- current_byte_offset += log_line.bytesize + 1
188
+ Rails.logger.silence(Logger::ERROR) do
189
+ each_matching_line(file_path, filter, regexp_mode, start_position, end_position) do |log_line, byte_offset|
190
+ # Buffer previous line and skip it to avoid cut-off lines at boundaries
191
+ if last_line
192
+ @batch_sender.add_line(render_log_line(last_line, byte_offset: last_byte_offset,
193
+ show_expand_button: show_expand_button))
194
+ line_count += 1
220
195
  end
196
+ last_line = log_line
197
+ last_byte_offset = byte_offset
221
198
  end
222
199
  end
223
200
 
@@ -249,6 +226,49 @@ module Onlylogs
249
226
  end
250
227
  end
251
228
 
229
+ # Yields [line, byte_offset] over the requested slice: through grep when there
230
+ # is a filter, straight off disk when there is not. Either way the offsets are
231
+ # the real ones, so "show around this line" can re-centre on them.
232
+ #
233
+ # A range that starts mid-line opens with a fragment of a line, which is
234
+ # dropped - but its bytes still count towards every offset after it.
235
+ def each_matching_line(file_path, filter, regexp_mode, start_position, end_position)
236
+ skip_first = start_position > 0
237
+
238
+ if filter.present?
239
+ @log_file.grep(filter, regexp_mode: regexp_mode, start_position: start_position,
240
+ end_position: end_position, timeout: Onlylogs.search_timeout) do |result|
241
+ break if reading_stopped?
242
+
243
+ if skip_first
244
+ skip_first = false
245
+ next
246
+ end
247
+
248
+ yield result[:content], result[:byte_offset]
249
+ end
250
+ else
251
+ offset = start_position
252
+
253
+ read_byte_range(file_path, start_position, end_position) do |log_line, raw_bytesize|
254
+ break if reading_stopped?
255
+
256
+ if skip_first
257
+ skip_first = false
258
+ offset += raw_bytesize
259
+ next
260
+ end
261
+
262
+ yield log_line, offset
263
+ offset += raw_bytesize
264
+ end
265
+ end
266
+ end
267
+
268
+ def reading_stopped?
269
+ @batch_sender.nil? || @log_watcher_running == false
270
+ end
271
+
252
272
  def read_byte_range(file_path, start_position, end_position)
253
273
  file_size = ::File.size(file_path)
254
274
  range_size = (end_position || file_size) - start_position
@@ -256,7 +276,7 @@ module Onlylogs
256
276
  return if start_position < 0 || range_size <= 0 || start_position >= file_size
257
277
 
258
278
  ::File.read(file_path, range_size, start_position).each_line do |line|
259
- yield line.chomp
279
+ yield line.chomp, line.bytesize
260
280
  end
261
281
  rescue => e
262
282
  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
  }