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 +4 -4
- data/README.md +39 -2
- data/app/channels/onlylogs/logs_channel.rb +90 -70
- data/app/javascript/onlylogs/controllers/keyboard_shortcuts_controller.js +15 -11
- data/app/javascript/onlylogs/controllers/log_streamer_controller.js +177 -203
- data/app/javascript/onlylogs/controllers/range_slider_controller.js +96 -0
- data/app/javascript/onlylogs/controllers/text_selection_controller.js +1 -0
- data/app/models/onlylogs/file.rb +13 -2
- data/app/models/onlylogs/grep.rb +14 -6
- data/app/views/onlylogs/logs/index.html.erb +25 -5
- data/app/views/onlylogs/shared/_log_container.html.erb +84 -37
- data/app/views/onlylogs/shared/_log_container_styles.html.erb +247 -49
- data/app/views/onlylogs/shared/_range_slider.html.erb +9 -5
- data/lib/onlylogs/continuous_log_writer.rb +161 -0
- data/lib/onlylogs/formatter.rb +4 -3
- data/lib/onlylogs/http_device.rb +319 -84
- data/lib/onlylogs/http_logger.rb +4 -7
- data/lib/onlylogs/spool.rb +61 -13
- data/lib/onlylogs/version.rb +1 -1
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 4707246899a958c58bf6ea58aba6f21234d938c660ce23dbad8df598654a4b5c
|
|
4
|
+
data.tar.gz: c46cf3df22e80cd90749bf144f62a271a9556b13ee718640377aa86f34608e05
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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 [
|
|
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::
|
|
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
|
-
|
|
20
|
-
|
|
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
|
-
|
|
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
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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 = ["
|
|
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.
|
|
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
|
-
|
|
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
|
}
|