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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 19813b5a5884acae634a3516e4cd23dc67fb5cb359062491a15d331f35de2448
4
- data.tar.gz: 158e9b56b481744b67a6d92b0835f1443f769b0f264149fb71fe1b69fc86e250
3
+ metadata.gz: f166264d89c5d2273751e3377ef236168da610b1d37d5ddf02d33959f2fe318d
4
+ data.tar.gz: a612d6738ee600171cd21a78b403d1abef06fdf5df893ebcc8c3401b092f6a51
5
5
  SHA512:
6
- metadata.gz: 9b2c8cd637017af2c310b06a406d75c0a70659244f6b93dfcb12b0f470337d87c8254e257fa4e4810faea6b12e0f2ad27a60b40d2c3423cc98deb21cf56df39d
7
- data.tar.gz: d2b9af11af98db9c2d92142553a1a7cc3eb915ddc398ed91b34a04d201e4cbda3dbc926bd007fef335071cbfc77e91ce22b3ff272c3eb7ffa8b607b032841cc6
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, end_position: end_position) do |result|
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
@@ -55,8 +55,12 @@ module Onlylogs
55
55
  true
56
56
  end
57
57
 
58
- def grep(filter, regexp_mode: false, start_position: 0, end_position: nil, &block)
59
- Grep.grep(filter, path, regexp_mode: regexp_mode, start_position: start_position, end_position: end_position) do |content|
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
@@ -1,21 +1,21 @@
1
+ require "timeout"
2
+
1
3
  module Onlylogs
2
4
  class Grep
3
- def self.grep(pattern, file_path, start_position: 0, end_position: nil, regexp_mode: false, &block)
4
- # Use the appropriate script based on configuration
5
- script_name = Onlylogs.ripgrep_enabled? ? "super_ripgrep" : "super_grep"
6
- super_grep_path = ::File.expand_path("../../../bin/#{script_name}", __dir__)
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
- command_args = [super_grep_path]
9
- command_args += ["--max-matches", Onlylogs.max_line_matches.to_s] if Onlylogs.max_line_matches.present?
10
- command_args << "--regexp" if regexp_mode
11
-
12
- # Add byte range parameters if specified
13
- if start_position > 0 || end_position
14
- command_args << "--start-position" << start_position.to_s
15
- command_args << "--end-position" << end_position.to_s if end_position
16
- end
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
- IO.popen(command_args, err: "/dev/null") do |io|
33
- io.each_line do |line|
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. Best-effort: advise is only a hint and is unsupported
59
- # on some platforms, so never let it break a search.
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
- # Validate range
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
- # Extract byte range using dd
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
- # Validate range
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
- # Extract byte range using dd
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)
@@ -1,3 +1,3 @@
1
1
  module Onlylogs
2
- VERSION = "0.7.0"
2
+ VERSION = "0.9.0"
3
3
  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.7.0
4
+ version: 0.9.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alessandro Rodi