legion-logging 1.5.6 → 1.5.21

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.
@@ -0,0 +1,143 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+
5
+ module Legion
6
+ module Logging
7
+ # Settings and descriptor detection shared by the byte-capped file and stdio sinks.
8
+ module Rotation
9
+ DEFAULTS = {
10
+ enabled: true,
11
+ max_size_mb: 100,
12
+ max_files: 5,
13
+ stdio: true
14
+ }.freeze
15
+
16
+ F_GETPATH = 50 # macOS fcntl(2) command; not exposed by the Fcntl module
17
+ MAXPATHLEN = 1024
18
+
19
+ class << self
20
+ # Normalizes rotation settings. Accepts nil, false (disabled), or a Hash
21
+ # with symbol or string keys. Missing keys fall back to DEFAULTS.
22
+ def resolve(raw:)
23
+ overrides = if raw == false
24
+ { enabled: false }
25
+ elsif raw.is_a?(Hash)
26
+ raw.transform_keys(&:to_sym)
27
+ else
28
+ {}
29
+ end
30
+ merged = DEFAULTS.merge(overrides.slice(*DEFAULTS.keys))
31
+ merged[:enabled] = merged[:enabled] != false
32
+ merged[:stdio] = merged[:stdio] != false
33
+ merged[:max_size_mb] = positive_number(merged[:max_size_mb], DEFAULTS[:max_size_mb])
34
+ merged[:max_files] = positive_number(merged[:max_files], DEFAULTS[:max_files]).to_i
35
+ merged[:max_files] = DEFAULTS[:max_files] unless merged[:max_files].positive?
36
+ bytes = merged[:max_size_mb] * 1024 * 1024
37
+ unless bytes.finite?
38
+ merged[:max_size_mb] = DEFAULTS[:max_size_mb]
39
+ bytes = merged[:max_size_mb] * 1024 * 1024
40
+ end
41
+ merged[:max_bytes] = [bytes.to_i, 1].max
42
+ merged
43
+ end
44
+
45
+ # Reads rotation settings from Legion::Settings once it has loaded.
46
+ # Guarded against re-entry because Settings may log while loading.
47
+ def from_settings
48
+ return nil unless defined?(Legion::Settings) && Legion::Settings.respond_to?(:[])
49
+ return nil if Thread.current[:legion_logging_rotation_lookup]
50
+
51
+ lookup_started = true
52
+ Thread.current[:legion_logging_rotation_lookup] = true
53
+ return nil if Legion::Settings.respond_to?(:loaded?) && !Legion::Settings.loaded?
54
+
55
+ logging = Legion::Settings[:logging]
56
+ logging[:rotation] if logging.is_a?(Hash)
57
+ rescue StandardError => e
58
+ warn("Legion::Logging::Rotation.from_settings failed: #{e.class}: #{e.message}")
59
+ nil
60
+ ensure
61
+ Thread.current[:legion_logging_rotation_lookup] = nil if lookup_started
62
+ end
63
+
64
+ # Shifts path.(n-1) -> path.n ... path -> path.1, dropping the oldest.
65
+ def shift_files(path:, max_files:)
66
+ oldest = "#{path}.#{max_files}"
67
+ FileUtils.rm(oldest, force: false) if File.exist?(oldest)
68
+ (max_files - 1).downto(1) do |i|
69
+ src = "#{path}.#{i}"
70
+ File.rename(src, "#{path}.#{i + 1}") if File.exist?(src)
71
+ end
72
+ File.rename(path, "#{path}.1") if File.exist?(path)
73
+ end
74
+
75
+ # Returns the filesystem path behind an IO if it is a regular file opened
76
+ # in append mode (launchd StandardOutPath, systemd append:, shell >>), or nil.
77
+ # Non-append redirects (shell >) are left alone.
78
+ def regular_file_path(io:)
79
+ return nil unless io.respond_to?(:stat) && io.stat.file?
80
+ return nil unless append_mode?(io: io)
81
+
82
+ path = fd_path(io)
83
+ return nil unless path && File.file?(path)
84
+ return nil unless same_file?(io: io, path: path)
85
+
86
+ path
87
+ rescue StandardError => e
88
+ warn("Legion::Logging::Rotation.regular_file_path failed: #{e.class}: #{e.message}")
89
+ nil
90
+ end
91
+
92
+ def same_file?(io:, path:)
93
+ return false unless File.exist?(path)
94
+
95
+ io_stat = io.stat
96
+ path_stat = File.stat(path)
97
+ io_stat.ino == path_stat.ino && io_stat.dev == path_stat.dev
98
+ rescue SystemCallError, IOError => e
99
+ warn("Legion::Logging::Rotation.same_file? path=#{path} failed: #{e.class}: #{e.message}")
100
+ false
101
+ end
102
+
103
+ def append_mode?(io:)
104
+ require 'fcntl'
105
+ io.fcntl(Fcntl::F_GETFL).anybits?(File::APPEND)
106
+ rescue NotImplementedError, SystemCallError, IOError => e
107
+ warn("Legion::Logging::Rotation.append_mode? failed: #{e.class}: #{e.message}")
108
+ false
109
+ end
110
+
111
+ private
112
+
113
+ def fd_path(io)
114
+ if io.respond_to?(:path)
115
+ path = File.expand_path(io.path)
116
+ return path if same_file?(io: io, path: path)
117
+ end
118
+
119
+ if RUBY_PLATFORM.include?('darwin')
120
+ require 'fcntl'
121
+ buf = "\0" * MAXPATHLEN
122
+ io.fcntl(F_GETPATH, buf)
123
+ buf.unpack1('Z*')
124
+ else
125
+ proc_link = "/proc/self/fd/#{io.fileno}"
126
+ File.readlink(proc_link) if File.symlink?(proc_link)
127
+ end
128
+ end
129
+
130
+ def positive_number(value, default)
131
+ number = Float(value)
132
+ number.positive? && number.finite? ? number : default
133
+ rescue ArgumentError, TypeError => e
134
+ warn("Legion::Logging::Rotation invalid numeric limit: #{e.class}: #{e.message}")
135
+ default
136
+ end
137
+ end
138
+ end
139
+ end
140
+ end
141
+
142
+ require_relative 'rotating_file'
143
+ require_relative 'stdio_rotator'
@@ -3,15 +3,37 @@
3
3
  module Legion
4
4
  module Logging
5
5
  module Settings
6
+ # Log levels forwarded to the transport bus (and in-process Hooks) by
7
+ # default. Preserves historic behavior: only warn/error/fatal logs build a
8
+ # structured event. Override at runtime via logging.transport.log_levels
9
+ # (e.g. add :debug/:info) — see Methods#forwardable_log_levels.
10
+ DEFAULT_FORWARD_LOG_LEVELS = %i[warn error fatal].freeze
11
+
6
12
  def self.default
7
13
  {
8
- level: :info,
9
- trace: true,
10
- trace_size: 4,
11
- extended: true,
12
- backtrace_limit: nil
14
+ level: :info,
15
+ trace: true,
16
+ trace_size: 4,
17
+ extended: true,
18
+ backtrace_limit: nil,
19
+ forward_log_levels: DEFAULT_FORWARD_LOG_LEVELS,
20
+ rotation: {
21
+ enabled: true,
22
+ max_size_mb: 100,
23
+ max_files: 5,
24
+ stdio: true
25
+ }
13
26
  }
14
27
  end
28
+
29
+ def self.async_buffer_size
30
+ return 10_000 unless defined?(Legion::Settings) && Legion::Settings.respond_to?(:[])
31
+ return 10_000 unless Legion::Settings.respond_to?(:loaded?) ? Legion::Settings.loaded? : true
32
+
33
+ logging = Legion::Settings[:logging]
34
+ async = logging[:async] if logging.is_a?(Hash)
35
+ async.is_a?(Hash) ? (async[:buffer_size] || 10_000) : 10_000
36
+ end
15
37
  end
16
38
  end
17
39
  end
@@ -26,7 +26,7 @@ module Legion
26
26
  end
27
27
  true
28
28
  rescue StandardError => e
29
- Legion::Logging.error("FileTransport ship failed: #{e.message}") if defined?(Legion::Logging)
29
+ Kernel.warn("FileTransport ship failed: #{e.message}")
30
30
  false
31
31
  end
32
32
 
@@ -40,6 +40,7 @@ module Legion
40
40
 
41
41
  def settings_path
42
42
  return nil unless defined?(Legion::Settings)
43
+ return nil unless Legion::Settings.respond_to?(:loaded?) ? Legion::Settings.loaded? : true
43
44
 
44
45
  Legion::Settings.dig(:logging, :shipper, :file, :path)
45
46
  end
@@ -24,7 +24,7 @@ module Legion
24
24
  response = post(uri, body)
25
25
  response.is_a?(Net::HTTPSuccess)
26
26
  rescue StandardError => e
27
- Legion::Logging.error("HttpTransport ship failed: #{e.message}") if defined?(Legion::Logging)
27
+ Kernel.warn("HttpTransport ship failed: #{e.message}")
28
28
  false
29
29
  end
30
30
 
@@ -36,7 +36,7 @@ module Legion
36
36
  apply_auth(req, uri)
37
37
 
38
38
  Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == 'https',
39
- open_timeout: 5, read_timeout: 10) do |http|
39
+ open_timeout: 5, read_timeout: 10, write_timeout: 10) do |http|
40
40
  http.request(req, body)
41
41
  end
42
42
  end
@@ -67,12 +67,14 @@ module Legion
67
67
 
68
68
  def auth_token
69
69
  return nil unless defined?(Legion::Settings)
70
+ return nil unless Legion::Settings.respond_to?(:loaded?) ? Legion::Settings.loaded? : true
70
71
 
71
72
  Legion::Settings.dig(:logging, :shipper, :auth_token)
72
73
  end
73
74
 
74
75
  def resolve_endpoint
75
76
  return nil unless defined?(Legion::Settings)
77
+ return nil unless Legion::Settings.respond_to?(:loaded?) ? Legion::Settings.loaded? : true
76
78
 
77
79
  Legion::Settings.dig(:logging, :shipper, :endpoint)
78
80
  end
@@ -8,134 +8,183 @@ module Legion
8
8
  module Logging
9
9
  module Shipper
10
10
  LEVEL_ORDER = %w[debug info warn error fatal].freeze
11
-
12
- TRANSPORTS = {
13
- file: FileTransport,
14
- http: HttpTransport
15
- }.freeze
11
+ TRANSPORTS = { file: FileTransport, http: HttpTransport }.freeze
12
+ MUTEX = Mutex.new
13
+ WAKE = ConditionVariable.new
14
+ CHANGED = ConditionVariable.new
15
+ private_constant :MUTEX, :WAKE, :CHANGED
16
+
17
+ @buffer = []
18
+ @accepted = 0
19
+ @delivered = 0
20
+ @attempt = 0
21
+ @flush_target = 0
16
22
 
17
23
  class << self
18
24
  def ship(event)
19
25
  return unless enabled?
20
26
  return unless shippable_level?(event[:level] || event['level'])
27
+ return unless TRANSPORTS.key?(transport_type)
21
28
 
22
- redacted = Redactor.redact(event)
23
- transport = TRANSPORTS[transport_type]
24
- buffer_event(redacted) if transport
25
- end
29
+ redacted = Redactor.redact(event)
30
+ raise ClosedQueueError, 'log shipper is stopping' unless start
26
31
 
27
- def flush
28
- @mutex ||= Mutex.new
29
- batch = @mutex.synchronize do
30
- return true if @buffer.nil? || @buffer.empty?
32
+ MUTEX.synchronize do
33
+ raise ThreadError, 'log shipper cannot enqueue from its delivery thread' if Thread.current.equal?(@flush_thread)
31
34
 
32
- flushed = @buffer.dup
33
- @buffer.clear
34
- flushed
35
+ CHANGED.wait(MUTEX) while !@stopping && @accepted - @delivered >= @capacity
36
+ raise ClosedQueueError, 'log shipper is stopping' if @stopping
37
+
38
+ @buffer << redacted
39
+ @accepted += 1
40
+ @flush_requested = true if @buffer.size >= @batch_size
41
+ WAKE.signal if @flush_requested
35
42
  end
43
+ true
44
+ end
45
+
46
+ def flush(timeout: 5)
47
+ return true if MUTEX.synchronize { @accepted == @delivered }
48
+ return false unless start
49
+
50
+ deadline = timeout && (::Process.clock_gettime(::Process::CLOCK_MONOTONIC) + timeout)
51
+ MUTEX.synchronize do
52
+ return false if Thread.current.equal?(@flush_thread)
53
+
54
+ target = @accepted
55
+ attempt = @attempt
56
+ @flush_target = [@flush_target, target].max
57
+ @flush_requested = true
58
+ WAKE.signal
59
+ while @delivered < target
60
+ return false if @attempt > attempt && !@last_success
61
+ return false unless @flush_thread&.alive?
36
62
 
37
- transport = TRANSPORTS[transport_type]
38
- return true unless transport
63
+ remaining = deadline && (deadline - ::Process.clock_gettime(::Process::CLOCK_MONOTONIC))
64
+ return false if remaining && remaining <= 0
39
65
 
40
- delivered = deliver(transport, batch)
41
- @mutex.synchronize { @buffer.prepend(*batch) } unless delivered
42
- delivered
66
+ CHANGED.wait(MUTEX, remaining)
67
+ end
68
+ true
69
+ end
43
70
  end
44
71
 
45
72
  def start
46
- @start_mutex ||= Mutex.new
47
- @start_mutex.synchronize do
48
- return unless enabled?
49
- return if @flush_thread&.alive?
50
-
51
- @buffer ||= []
52
- @mutex ||= Mutex.new
53
- @running = true
54
- interval = flush_interval
55
- @flush_thread = Thread.new do
56
- while @running
57
- sleep interval
58
- flush
59
- end
73
+ return false unless enabled?
74
+
75
+ MUTEX.synchronize do
76
+ return !@stopping if @flush_thread&.alive?
77
+
78
+ @capacity = Legion::Settings.dig(:logging, :shipper, :buffer_size) || 10_000
79
+ @batch_size = [batch_size, @capacity].min
80
+ @interval = flush_interval
81
+ unless @capacity.is_a?(Integer) && @capacity.positive? && @batch_size.is_a?(Integer) && @batch_size.positive? &&
82
+ @interval.is_a?(Numeric) && @interval.positive?
83
+ raise ArgumentError, 'shipper buffer_size, batch_size, and flush_interval must be positive'
60
84
  end
85
+
86
+ @transport = TRANSPORTS[transport_type]
87
+ return false unless @transport
88
+
89
+ @stopping = false
90
+ @flush_requested = @buffer.size >= @batch_size
91
+ @flush_thread = Thread.new { consume }
61
92
  @flush_thread.name = 'legion-log-shipper'
62
93
  @flush_thread.abort_on_exception = false
94
+ true
63
95
  end
64
96
  end
65
97
 
66
- def stop
67
- @running = false
68
- thread = @flush_thread
69
- @flush_thread = nil
70
- thread&.join(5)
71
- flush
98
+ def stop(timeout: 5)
99
+ deadline = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) + timeout if timeout
100
+ thread = MUTEX.synchronize do
101
+ @stopping = true
102
+ WAKE.broadcast
103
+ CHANGED.broadcast
104
+ if !@flush_thread&.alive? && @accepted > @delivered
105
+ @flush_thread = Thread.new { consume }
106
+ @flush_thread.name = 'legion-log-shipper'
107
+ @flush_thread.abort_on_exception = false
108
+ end
109
+ @flush_thread
110
+ end
111
+ return MUTEX.synchronize { @accepted == @delivered } unless thread
112
+ return false if Thread.current.equal?(thread)
113
+
114
+ remaining = [deadline - ::Process.clock_gettime(::Process::CLOCK_MONOTONIC), 0].max if deadline
115
+ thread.join(remaining)
116
+ MUTEX.synchronize { !thread.alive? && @accepted == @delivered }
72
117
  end
73
118
 
74
119
  def enabled?
75
120
  return false unless defined?(Legion::Settings)
121
+ return false unless Legion::Settings.respond_to?(:loaded?) ? Legion::Settings.loaded? : true
76
122
 
77
123
  Legion::Settings.dig(:logging, :shipper, :enabled) == true
78
124
  end
79
125
 
80
126
  private
81
127
 
82
- def buffer_event(event)
83
- @buffer ||= []
84
- @mutex ||= Mutex.new
85
-
86
- full = false
87
- @mutex.synchronize do
88
- @buffer << event
89
- full = @buffer.size >= batch_size
128
+ def consume
129
+ loop do
130
+ batch = MUTEX.synchronize do
131
+ WAKE.wait(MUTEX, @interval) unless @flush_requested || @stopping
132
+ @flush_requested = false
133
+ @buffer.shift(@batch_size)
134
+ end
135
+ delivered = batch.empty? || deliver(@transport, batch)
136
+ stopping = MUTEX.synchronize do
137
+ @attempt += 1
138
+ @last_success = delivered
139
+ if delivered
140
+ @delivered += batch.size
141
+ @flush_requested = @buffer.size >= @batch_size || @delivered < @flush_target
142
+ else
143
+ @buffer.prepend(*batch)
144
+ end
145
+ CHANGED.broadcast
146
+ @stopping && (!delivered || @buffer.empty?)
147
+ end
148
+ break if stopping
149
+ end
150
+ ensure
151
+ MUTEX.synchronize do
152
+ @stopping = true
153
+ CHANGED.broadcast
90
154
  end
91
-
92
- flush if full
93
155
  end
94
156
 
95
157
  def deliver(transport, batch)
96
- if transport.respond_to?(:ship_batch)
97
- transport.ship_batch(batch)
98
- else
99
- batch.all? { |event| transport.ship(event) }
100
- end
158
+ transport.ship_batch(batch)
101
159
  rescue StandardError => e
102
- Legion::Logging.error("Shipper deliver failed: #{e.message}") if defined?(Legion::Logging)
160
+ Kernel.warn("Shipper deliver failed: #{e.message}")
103
161
  false
104
162
  end
105
163
 
106
164
  def shippable_level?(level)
107
165
  return true if level.nil?
108
166
 
109
- min = minimum_level
110
- LEVEL_ORDER.index(level.to_s.downcase).to_i >= LEVEL_ORDER.index(min).to_i
167
+ LEVEL_ORDER.index(level.to_s.downcase).to_i >= LEVEL_ORDER.index(minimum_level).to_i
111
168
  end
112
169
 
113
170
  def transport_type
114
- return :file unless defined?(Legion::Settings)
115
-
116
- key = Legion::Settings.dig(:logging, :shipper, :transport)
171
+ key = Legion::Settings.dig(:logging, :shipper, :transport) if defined?(Legion::Settings)
117
172
  key ? key.to_sym : :file
118
173
  end
119
174
 
120
175
  def batch_size
121
- return 100 unless defined?(Legion::Settings)
122
-
123
176
  Legion::Settings.dig(:logging, :shipper, :batch_size) || 100
124
177
  end
125
178
 
126
179
  def flush_interval
127
- return 5 unless defined?(Legion::Settings)
128
-
129
180
  Legion::Settings.dig(:logging, :shipper, :flush_interval) || 5
130
181
  end
131
182
 
132
183
  def minimum_level
133
- return 'warn' unless defined?(Legion::Settings)
134
-
135
184
  levels = Legion::Settings.dig(:logging, :shipper, :levels)
136
185
  return 'warn' unless levels.is_a?(Array) && !levels.empty?
137
186
 
138
- levels.min_by { |l| LEVEL_ORDER.index(l.to_s) || 99 }.to_s
187
+ levels.min_by { |level| LEVEL_ORDER.index(level.to_s) || 99 }.to_s
139
188
  end
140
189
  end
141
190
  end
@@ -29,7 +29,7 @@ module Legion
29
29
  req.body = ::JSON.dump({ event: redact_phi(event.to_s), time: Time.now.to_f })
30
30
 
31
31
  Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == 'https',
32
- open_timeout: 5, read_timeout: 10) do |http|
32
+ open_timeout: 5, read_timeout: 10, write_timeout: 10) do |http|
33
33
  http.request(req)
34
34
  end
35
35
  rescue StandardError => e