onlylogs 0.7.0 → 0.9.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 +16 -0
- data/app/channels/onlylogs/logs_channel.rb +7 -1
- data/app/models/onlylogs/file.rb +6 -2
- data/app/models/onlylogs/grep.rb +118 -20
- data/bin/super_grep +9 -17
- data/bin/super_ripgrep +9 -17
- data/lib/onlylogs/configuration.rb +13 -1
- data/lib/onlylogs/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f166264d89c5d2273751e3377ef236168da610b1d37d5ddf02d33959f2fe318d
|
|
4
|
+
data.tar.gz: a612d6738ee600171cd21a78b403d1abef06fdf5df893ebcc8c3401b092f6a51
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 6c2d38d16486ea48cd8e6c695e1b9234dbe8e300f45342afa163adfef2e532cdf1cf0cdecdca411c9a13a80c6c4f32aa5c33a82dfc9137c2da35f72cffa0f2c6
|
|
7
|
+
data.tar.gz: d4b8b016e5c089712e288d6533b613571f5edee524b2784a3aff80b4d8701913aabb169f72cdedb4220c01a86f3be33d0c52eedc644538db1a3fe3e259e947c9
|
data/README.md
CHANGED
|
@@ -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.
|
|
@@ -173,7 +173,8 @@ module Onlylogs
|
|
|
173
173
|
|
|
174
174
|
if filter.present?
|
|
175
175
|
# Use grep for filtered search
|
|
176
|
-
@log_file.grep(filter, regexp_mode: regexp_mode, start_position: start_position,
|
|
176
|
+
@log_file.grep(filter, regexp_mode: regexp_mode, start_position: start_position,
|
|
177
|
+
end_position: end_position, timeout: Onlylogs.search_timeout) do |result|
|
|
177
178
|
break if @batch_sender.nil? || @log_watcher_running == false
|
|
178
179
|
|
|
179
180
|
# Skip first line if start_position > 0 (line is cut off at byte boundary)
|
|
@@ -234,6 +235,11 @@ module Onlylogs
|
|
|
234
235
|
else
|
|
235
236
|
transmit({action: "finish", content: "Search finished."})
|
|
236
237
|
end
|
|
238
|
+
rescue Onlylogs::Grep::TimeoutError
|
|
239
|
+
@batch_sender&.stop
|
|
240
|
+
transmit({action: "finish",
|
|
241
|
+
content: "Search stopped after #{Onlylogs.search_timeout} seconds and did not reach " \
|
|
242
|
+
"the end of the file. Narrow the range or the filter and search again."})
|
|
237
243
|
ensure
|
|
238
244
|
# Always cleanup even if interrupted or error occurs
|
|
239
245
|
@batch_sender&.stop
|
data/app/models/onlylogs/file.rb
CHANGED
|
@@ -55,8 +55,12 @@ module Onlylogs
|
|
|
55
55
|
true
|
|
56
56
|
end
|
|
57
57
|
|
|
58
|
-
|
|
59
|
-
|
|
58
|
+
# +timeout+ is in seconds and defaults to nil, meaning unbounded. See
|
|
59
|
+
# Grep.grep.
|
|
60
|
+
def grep(filter, regexp_mode: false, start_position: 0, end_position: nil,
|
|
61
|
+
max_matches: Onlylogs.max_line_matches, timeout: nil, &block)
|
|
62
|
+
Grep.grep(filter, path, regexp_mode: regexp_mode, start_position: start_position,
|
|
63
|
+
end_position: end_position, max_matches: max_matches, timeout: timeout) do |content|
|
|
60
64
|
yield content
|
|
61
65
|
end
|
|
62
66
|
end
|
data/app/models/onlylogs/grep.rb
CHANGED
|
@@ -1,21 +1,21 @@
|
|
|
1
|
+
require "timeout"
|
|
2
|
+
|
|
1
3
|
module Onlylogs
|
|
2
4
|
class Grep
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
5
|
+
# Raised when a search runs past its own +timeout+. It inherits from
|
|
6
|
+
# Timeout::Error so callers that already wrap searches in Timeout.timeout
|
|
7
|
+
# can keep a single rescue.
|
|
8
|
+
class TimeoutError < ::Timeout::Error; end
|
|
7
9
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
command_args += [pattern, file_path]
|
|
10
|
+
# +timeout+ is in seconds and defaults to nil, which is no deadline at all:
|
|
11
|
+
# the search runs until it finishes or the caller abandons it. The default
|
|
12
|
+
# is deliberately not a number, because only the caller knows how long it
|
|
13
|
+
# can afford to hold the thread it runs on. Anything serving a request
|
|
14
|
+
# should pass one.
|
|
15
|
+
def self.grep(pattern, file_path, start_position: 0, end_position: nil, regexp_mode: false,
|
|
16
|
+
max_matches: Onlylogs.max_line_matches, timeout: nil, &block)
|
|
17
|
+
command_args = search_command(pattern, file_path, start_position: start_position,
|
|
18
|
+
end_position: end_position, regexp_mode: regexp_mode, max_matches: max_matches)
|
|
19
19
|
|
|
20
20
|
results = []
|
|
21
21
|
|
|
@@ -23,20 +23,25 @@ module Onlylogs
|
|
|
23
23
|
parse_line = if Onlylogs.ripgrep_enabled?
|
|
24
24
|
->(line) {
|
|
25
25
|
parts = line.split(":", 2)
|
|
26
|
-
[parts[0].to_i, parts[1] || ""]
|
|
26
|
+
[parts[0].to_i + start_position, parts[1] || ""]
|
|
27
27
|
}
|
|
28
28
|
else
|
|
29
29
|
->(line) { [nil, line] }
|
|
30
30
|
end
|
|
31
31
|
|
|
32
|
-
|
|
33
|
-
|
|
32
|
+
matches = 0
|
|
33
|
+
|
|
34
|
+
ActiveSupport::Notifications.instrument("search.onlylogs", file_path: file_path,
|
|
35
|
+
query: pattern, regexp: regexp_mode, start_position: start_position,
|
|
36
|
+
end_position: end_position, max_matches: max_matches) do |payload|
|
|
37
|
+
each_output_line(command_args, timeout: timeout) do |line|
|
|
34
38
|
byte_offset, content = parse_line.call(line.chomp)
|
|
35
39
|
|
|
36
40
|
# Use String.new to create a copy and prevent memory retention from IO buffers
|
|
37
41
|
content = String.new(content, encoding: Encoding::UTF_8).scrub
|
|
38
42
|
|
|
39
43
|
result = {byte_offset: byte_offset, content: content}
|
|
44
|
+
matches += 1
|
|
40
45
|
|
|
41
46
|
if block_given?
|
|
42
47
|
yield result
|
|
@@ -44,19 +49,112 @@ module Onlylogs
|
|
|
44
49
|
results << result
|
|
45
50
|
end
|
|
46
51
|
end
|
|
52
|
+
rescue TimeoutError
|
|
53
|
+
payload[:timed_out] = true
|
|
54
|
+
raise
|
|
47
55
|
ensure
|
|
56
|
+
payload[:matches] = matches
|
|
57
|
+
payload[:timed_out] ||= false
|
|
48
58
|
drop_page_cache(file_path)
|
|
49
59
|
end
|
|
50
60
|
|
|
51
61
|
block_given? ? nil : results
|
|
52
62
|
end
|
|
53
63
|
|
|
64
|
+
def self.search_command(pattern, file_path, start_position: 0, end_position: nil, regexp_mode: false,
|
|
65
|
+
max_matches: Onlylogs.max_line_matches)
|
|
66
|
+
script_name = Onlylogs.ripgrep_enabled? ? "super_ripgrep" : "super_grep"
|
|
67
|
+
super_grep_path = ::File.expand_path("../../../bin/#{script_name}", __dir__)
|
|
68
|
+
|
|
69
|
+
command_args = [super_grep_path]
|
|
70
|
+
command_args += ["--max-matches", max_matches.to_s] if max_matches.present?
|
|
71
|
+
command_args << "--regexp" if regexp_mode
|
|
72
|
+
|
|
73
|
+
# Add byte range parameters if specified
|
|
74
|
+
if start_position > 0 || end_position
|
|
75
|
+
command_args << "--start-position" << start_position.to_s
|
|
76
|
+
command_args << "--end-position" << end_position.to_s if end_position
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
command_args + [pattern, file_path]
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Runs the search subprocess and yields its output line by line.
|
|
83
|
+
#
|
|
84
|
+
# The child is a shell pipeline (tail | head | rg) that can spend minutes
|
|
85
|
+
# scanning a multi-GB file, so two things have to hold. timeout(1) bounds
|
|
86
|
+
# the run and escalates TERM to KILL on the whole pipeline by itself, which
|
|
87
|
+
# covers the deadline. The rest is the caller walking away early - a break
|
|
88
|
+
# out of the yield, an exception, a dropped connection - and there the order
|
|
89
|
+
# below is the point: signal the process group *before* closing the pipe.
|
|
90
|
+
# Closing first waits for a child that, having matched nothing, never wrote
|
|
91
|
+
# and so never received SIGPIPE. That is how a 25 second timeout once turned
|
|
92
|
+
# into a 24 minute request.
|
|
93
|
+
def self.each_output_line(command_args, timeout: nil, &block)
|
|
94
|
+
reader, writer = IO.pipe
|
|
95
|
+
|
|
96
|
+
begin
|
|
97
|
+
pid = Process.spawn(*bounded(command_args, timeout), out: writer, err: ::File::NULL, pgroup: true)
|
|
98
|
+
rescue
|
|
99
|
+
reader.close
|
|
100
|
+
raise
|
|
101
|
+
ensure
|
|
102
|
+
writer.close
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
begin
|
|
106
|
+
reader.each_line(&block)
|
|
107
|
+
ensure
|
|
108
|
+
status = stop(pid)
|
|
109
|
+
reader.close
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
raise TimeoutError, "search exceeded #{timeout}s" if status&.exitstatus == TIMED_OUT_EXIT_STATUS
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# timeout(1) exits with this when it had to stop the command.
|
|
116
|
+
TIMED_OUT_EXIT_STATUS = 124
|
|
117
|
+
KILL_GRACE_PERIOD = 0.5
|
|
118
|
+
|
|
119
|
+
def self.bounded(command_args, timeout)
|
|
120
|
+
return command_args unless timeout && timeout_command_available?
|
|
121
|
+
|
|
122
|
+
["timeout", "-k", KILL_GRACE_PERIOD.to_s, timeout.to_s, *command_args]
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Not part of a BSD userland, so a machine without GNU coreutils falls back
|
|
126
|
+
# to whatever deadline the caller imposes. The kill path below still works.
|
|
127
|
+
def self.timeout_command_available?
|
|
128
|
+
return @timeout_command_available if defined?(@timeout_command_available)
|
|
129
|
+
|
|
130
|
+
@timeout_command_available = system("command -v timeout > /dev/null 2>&1")
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Runs while unwinding from an async exception often enough that a second
|
|
134
|
+
# one - another Timeout::Error, a Thread#kill from a shutting down server -
|
|
135
|
+
# could otherwise land between the signal and the reap and leave the
|
|
136
|
+
# pipeline running with nobody left to stop it.
|
|
137
|
+
def self.stop(pid)
|
|
138
|
+
Thread.handle_interrupt(::Exception => :never) do
|
|
139
|
+
begin
|
|
140
|
+
Process.kill("TERM", -pid)
|
|
141
|
+
rescue Errno::ESRCH, Errno::EPERM
|
|
142
|
+
nil
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
Process.waitpid2(pid).last
|
|
146
|
+
end
|
|
147
|
+
rescue Errno::ECHILD
|
|
148
|
+
nil
|
|
149
|
+
end
|
|
150
|
+
|
|
54
151
|
# Searching a large log file pulls the whole file into the OS page cache.
|
|
55
152
|
# In a container the kernel charges that cache to the cgroup, so a few
|
|
56
153
|
# searches over multi-GB logs can exhaust the memory limit and trigger an
|
|
57
154
|
# OOM kill even though no Ruby memory leaked. Hint the kernel to drop the
|
|
58
|
-
# pages we just read
|
|
59
|
-
#
|
|
155
|
+
# pages we just read, once the child is gone and nothing is refilling them.
|
|
156
|
+
# Best-effort: advise is only a hint and is unsupported on some platforms,
|
|
157
|
+
# so never let it break a search.
|
|
60
158
|
def self.drop_page_cache(file_path)
|
|
61
159
|
::File.open(file_path) { |file| file.advise(:dontneed) }
|
|
62
160
|
rescue
|
data/bin/super_grep
CHANGED
|
@@ -5,10 +5,6 @@ export LC_ALL=C
|
|
|
5
5
|
max_matches=""
|
|
6
6
|
start_position=""
|
|
7
7
|
end_position=""
|
|
8
|
-
# Block size for dd operations. Performance testing shows no significant
|
|
9
|
-
# difference across block sizes (4K-8M) on typical log files. 1M balances
|
|
10
|
-
# memory usage and I/O efficiency.
|
|
11
|
-
block_size="${BLOCK_SIZE:-1M}"
|
|
12
8
|
|
|
13
9
|
while [[ $# -gt 0 ]]; do
|
|
14
10
|
case "$1" in
|
|
@@ -67,26 +63,22 @@ fi
|
|
|
67
63
|
|
|
68
64
|
# Handle byte range if specified
|
|
69
65
|
if [ -n "$start_position" ] || [ -n "$end_position" ]; then
|
|
66
|
+
case "${start_position:-0}${end_position:-0}" in
|
|
67
|
+
*[!0-9]*) exit 1 ;;
|
|
68
|
+
esac
|
|
69
|
+
|
|
70
70
|
file_size=$(wc -c < "$file")
|
|
71
71
|
range_start=${start_position:-0}
|
|
72
72
|
range_end=${end_position:-$file_size}
|
|
73
73
|
range_size=$((range_end - range_start))
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
if [ $range_start -lt 0 ] || [ $range_size -le 0 ] || [ $range_start -ge $file_size ]; then
|
|
74
|
+
|
|
75
|
+
if [ $range_size -le 0 ] || [ $range_start -ge $file_size ]; then
|
|
77
76
|
exit 0
|
|
78
77
|
fi
|
|
79
|
-
|
|
80
|
-
# Adjust if exceeds file size
|
|
78
|
+
|
|
81
79
|
[ $range_end -gt $file_size ] && range_end=$file_size && range_size=$((range_end - range_start))
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
start_mb=$((range_start / 1048576))
|
|
85
|
-
start_offset=$((range_start % 1048576))
|
|
86
|
-
count_mb=$(((range_size + 1048576 - 1) / 1048576))
|
|
87
|
-
|
|
88
|
-
dd if="$file" bs="$block_size" skip=$start_mb count=$count_mb 2>/dev/null | \
|
|
89
|
-
dd bs=1 skip=$start_offset count=$range_size 2>/dev/null | \
|
|
80
|
+
|
|
81
|
+
tail -c +$((range_start + 1)) "$file" | head -c $range_size | \
|
|
90
82
|
"${grep_cmd[@]}" "$query_regex"
|
|
91
83
|
else
|
|
92
84
|
# Search entire file
|
data/bin/super_ripgrep
CHANGED
|
@@ -5,10 +5,6 @@ export LC_ALL=C
|
|
|
5
5
|
max_matches=""
|
|
6
6
|
start_position=""
|
|
7
7
|
end_position=""
|
|
8
|
-
# Block size for dd operations. Performance testing shows no significant
|
|
9
|
-
# difference across block sizes (4K-8M) on typical log files. 1M balances
|
|
10
|
-
# memory usage and I/O efficiency.
|
|
11
|
-
block_size="${BLOCK_SIZE:-1M}"
|
|
12
8
|
|
|
13
9
|
while [[ $# -gt 0 ]]; do
|
|
14
10
|
case "$1" in
|
|
@@ -69,26 +65,22 @@ rg_cmd="rg --color=never --no-filename --byte-offset --no-mmap"
|
|
|
69
65
|
|
|
70
66
|
# Handle byte range if specified
|
|
71
67
|
if [ -n "$start_position" ] || [ -n "$end_position" ]; then
|
|
68
|
+
case "${start_position:-0}${end_position:-0}" in
|
|
69
|
+
*[!0-9]*) exit 1 ;;
|
|
70
|
+
esac
|
|
71
|
+
|
|
72
72
|
file_size=$(wc -c < "$file")
|
|
73
73
|
range_start=${start_position:-0}
|
|
74
74
|
range_end=${end_position:-$file_size}
|
|
75
75
|
range_size=$((range_end - range_start))
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
if [ $range_start -lt 0 ] || [ $range_size -le 0 ] || [ $range_start -ge $file_size ]; then
|
|
76
|
+
|
|
77
|
+
if [ $range_size -le 0 ] || [ $range_start -ge $file_size ]; then
|
|
79
78
|
exit 0
|
|
80
79
|
fi
|
|
81
|
-
|
|
82
|
-
# Adjust if exceeds file size
|
|
80
|
+
|
|
83
81
|
[ $range_end -gt $file_size ] && range_end=$file_size && range_size=$((range_end - range_start))
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
start_mb=$((range_start / 1048576))
|
|
87
|
-
start_offset=$((range_start % 1048576))
|
|
88
|
-
count_mb=$(((range_size + 1048576 - 1) / 1048576))
|
|
89
|
-
|
|
90
|
-
dd if="$file" bs="$block_size" skip=$start_mb count=$count_mb 2>/dev/null | \
|
|
91
|
-
dd bs=1 skip=$start_offset count=$range_size 2>/dev/null | \
|
|
82
|
+
|
|
83
|
+
tail -c +$((range_start + 1)) "$file" | head -c $range_size | \
|
|
92
84
|
$rg_cmd -e "$query_regex"
|
|
93
85
|
else
|
|
94
86
|
# Search entire file
|
|
@@ -4,7 +4,14 @@ module Onlylogs
|
|
|
4
4
|
class Configuration
|
|
5
5
|
attr_accessor :log_file_patterns, :default_log_file_path, :basic_auth_user, :basic_auth_password,
|
|
6
6
|
:parent_controller, :disable_basic_authentication, :ripgrep_enabled, :editor,
|
|
7
|
-
:max_line_matches
|
|
7
|
+
:max_line_matches, :search_timeout
|
|
8
|
+
|
|
9
|
+
# Seconds a viewer search may run before it is stopped. Bounded by default:
|
|
10
|
+
# a search holds a CPU for as long as it runs, and an unbounded one over a
|
|
11
|
+
# multi-GB file can starve everything else the host is doing. Generous
|
|
12
|
+
# enough for a full scan of a large file - set it to nil to remove the
|
|
13
|
+
# ceiling entirely.
|
|
14
|
+
DEFAULT_SEARCH_TIMEOUT = 120
|
|
8
15
|
|
|
9
16
|
def initialize
|
|
10
17
|
@log_file_patterns = default_log_file_patterns
|
|
@@ -16,6 +23,7 @@ module Onlylogs
|
|
|
16
23
|
@ripgrep_enabled = default_ripgrep_enabled
|
|
17
24
|
@editor = nil
|
|
18
25
|
@max_line_matches = 100000
|
|
26
|
+
@search_timeout = DEFAULT_SEARCH_TIMEOUT
|
|
19
27
|
end
|
|
20
28
|
|
|
21
29
|
def configure
|
|
@@ -147,6 +155,10 @@ module Onlylogs
|
|
|
147
155
|
configuration.max_line_matches
|
|
148
156
|
end
|
|
149
157
|
|
|
158
|
+
def self.search_timeout
|
|
159
|
+
configuration.search_timeout
|
|
160
|
+
end
|
|
161
|
+
|
|
150
162
|
def self.allowed_file_patterns_for(pattern)
|
|
151
163
|
absolute_pattern = ::File.expand_path(pattern.to_s)
|
|
152
164
|
if glob_pattern?(absolute_pattern)
|
data/lib/onlylogs/version.rb
CHANGED