logstash-logger 0.26.1 → 1.1.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.
Files changed (71) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/tests.yml +53 -0
  3. data/.gitignore +1 -0
  4. data/.rubocop.yml +85 -105
  5. data/Appraisals +6 -20
  6. data/CHANGELOG.md +61 -0
  7. data/Gemfile +2 -2
  8. data/README.md +12 -833
  9. data/docs/buffering.md +86 -0
  10. data/docs/customization.md +86 -0
  11. data/docs/outputs.md +65 -0
  12. data/docs/rails.md +349 -0
  13. data/docs/ssl.md +102 -0
  14. data/docs/troubleshooting.md +106 -0
  15. data/docs/usage.md +148 -0
  16. data/gemfiles/rails_8.0.gemfile +10 -0
  17. data/gemfiles/rails_8.1.gemfile +9 -0
  18. data/lib/logstash-logger/buffer.rb +15 -5
  19. data/lib/logstash-logger/configuration.rb +1 -2
  20. data/lib/logstash-logger/device/aws_stream.rb +96 -17
  21. data/lib/logstash-logger/device/base.rb +2 -2
  22. data/lib/logstash-logger/device/connectable.rb +30 -12
  23. data/lib/logstash-logger/device/file.rb +21 -4
  24. data/lib/logstash-logger/device/firehose.rb +6 -0
  25. data/lib/logstash-logger/device/http.rb +104 -0
  26. data/lib/logstash-logger/device/kafka.rb +182 -38
  27. data/lib/logstash-logger/device/kinesis.rb +10 -1
  28. data/lib/logstash-logger/device/redis.rb +8 -1
  29. data/lib/logstash-logger/device/tcp.rb +54 -12
  30. data/lib/logstash-logger/device.rb +24 -2
  31. data/lib/logstash-logger/formatter/base.rb +97 -11
  32. data/lib/logstash-logger/formatter/cee_syslog.rb +8 -11
  33. data/lib/logstash-logger/formatter/json.rb +13 -0
  34. data/lib/logstash-logger/formatter/json_lines.rb +13 -0
  35. data/lib/logstash-logger/formatter.rb +14 -6
  36. data/lib/logstash-logger/logger.rb +6 -19
  37. data/lib/logstash-logger/multi_logger.rb +2 -1
  38. data/lib/logstash-logger/railtie.rb +1 -1
  39. data/lib/logstash-logger/tagged_logging.rb +3 -1
  40. data/lib/logstash-logger/version.rb +1 -1
  41. data/logstash-logger.gemspec +8 -4
  42. data/spec/device/aws_stream_limits_spec.rb +219 -0
  43. data/spec/device/connectable_spec.rb +51 -0
  44. data/spec/device/file_spec.rb +65 -0
  45. data/spec/device/firehose_spec.rb +4 -1
  46. data/spec/device/http_spec.rb +176 -0
  47. data/spec/device/kafka_spec.rb +429 -14
  48. data/spec/device/kinesis_spec.rb +4 -1
  49. data/spec/device/tcp_spec.rb +94 -5
  50. data/spec/device/tcp_tls_integration_spec.rb +91 -0
  51. data/spec/device_spec.rb +13 -0
  52. data/spec/formatter/base_spec.rb +82 -1
  53. data/spec/formatter/cee_syslog_spec.rb +71 -19
  54. data/spec/formatter/framing_spec.rb +58 -0
  55. data/spec/formatter/json_lines_spec.rb +41 -0
  56. data/spec/formatter/json_spec.rb +49 -0
  57. data/spec/formatter_spec.rb +19 -2
  58. data/spec/logger_spec.rb +5 -5
  59. data/spec/multi_logger_spec.rb +16 -0
  60. data/spec/spec_helper.rb +7 -5
  61. data/spec/support/tls_helper.rb +30 -0
  62. data/spec/support/unserializable_value.rb +10 -0
  63. data/spec/tagged_logging_spec.rb +15 -0
  64. metadata +101 -16
  65. data/.travis.yml +0 -26
  66. data/gemfiles/rails_3.2.gemfile +0 -9
  67. data/gemfiles/rails_4.0.gemfile +0 -9
  68. data/gemfiles/rails_4.1.gemfile +0 -9
  69. data/gemfiles/rails_4.2.gemfile +0 -9
  70. data/gemfiles/rails_5.0.gemfile +0 -9
  71. data/gemfiles/rails_5.1.gemfile +0 -9
@@ -1,21 +1,18 @@
1
1
  module LogStashLogger
2
2
  module Formatter
3
3
  class CeeSyslog < Cee
4
- def call(severity, time, progname, message)
5
- @progname = progname
6
- super
7
- end
8
-
9
4
  private
10
5
 
11
- def build_facility(host)
12
- facility = host.dup
13
- facility << " #{@progname}" if @progname
14
- facility
6
+ def frame_event(event, progname)
7
+ "#{build_header(progname)}:#{format_event(event)}\n"
15
8
  end
16
9
 
17
- def format_event(event)
18
- "#{build_facility(event["host".freeze])}:#{super}\n"
10
+ # The header names this process, never a value taken from the logged
11
+ # content, and always stays on one line.
12
+ def build_header(progname)
13
+ header = HOST['hostname'.freeze].to_s.dup
14
+ header << " #{progname}" if progname
15
+ header.gsub(/[[:cntrl:]]/, ' '.freeze)
19
16
  end
20
17
  end
21
18
  end
@@ -4,7 +4,20 @@ module LogStashLogger
4
4
  private
5
5
 
6
6
  def format_event(event)
7
+ # Proactively check for encoding issues to handle cross-platform differences.
8
+ # Some Ruby implementations (e.g., JRuby) may not raise exceptions during
9
+ # JSON encoding but produce malformed output instead.
10
+ if message_has_encoding_issue?(event)
11
+ log_error(Encoding::InvalidByteSequenceError.new("Invalid encoding in message"))
12
+ return force_utf8_encoding(event).to_json
13
+ end
14
+
7
15
  event.to_json
16
+ rescue Encoding::UndefinedConversionError,
17
+ Encoding::InvalidByteSequenceError,
18
+ JSON::GeneratorError => e
19
+ log_error(e)
20
+ force_utf8_encoding(event).to_json
8
21
  end
9
22
  end
10
23
  end
@@ -4,7 +4,20 @@ module LogStashLogger
4
4
  private
5
5
 
6
6
  def format_event(event)
7
+ # Proactively check for encoding issues to handle cross-platform differences.
8
+ # Some Ruby implementations (e.g., JRuby) may not raise exceptions during
9
+ # JSON encoding but produce malformed output instead.
10
+ if message_has_encoding_issue?(event)
11
+ log_error(Encoding::InvalidByteSequenceError.new("Invalid encoding in message"))
12
+ return "#{force_utf8_encoding(event).to_json}\n"
13
+ end
14
+
7
15
  "#{event.to_json}\n"
16
+ rescue Encoding::UndefinedConversionError,
17
+ Encoding::InvalidByteSequenceError,
18
+ JSON::GeneratorError => e
19
+ log_error(e)
20
+ "#{force_utf8_encoding(event).to_json}\n"
8
21
  end
9
22
  end
10
23
  end
@@ -10,20 +10,20 @@ module LogStashLogger
10
10
  autoload :Cee, 'logstash-logger/formatter/cee'
11
11
  autoload :CeeSyslog, 'logstash-logger/formatter/cee_syslog'
12
12
 
13
- def self.new(formatter_type, customize_event: nil)
14
- build_formatter(formatter_type, customize_event)
13
+ def self.new(formatter_type, customize_event: nil, error_logger: LogStashLogger.configuration.default_error_logger)
14
+ build_formatter(formatter_type, customize_event, error_logger)
15
15
  end
16
16
 
17
- def self.build_formatter(formatter_type, customize_event)
17
+ def self.build_formatter(formatter_type, customize_event, error_logger)
18
18
  formatter_type ||= DEFAULT_FORMATTER
19
19
 
20
20
  formatter = if custom_formatter_instance?(formatter_type)
21
21
  formatter_type
22
22
  elsif custom_formatter_class?(formatter_type)
23
- formatter_type.new
23
+ initialize_custom_formatter_klass(formatter_type, customize_event)
24
24
  else
25
- formatter_klass(formatter_type).new(customize_event: customize_event)
26
- end
25
+ formatter_klass(formatter_type).new(customize_event: customize_event, error_logger: error_logger)
26
+ end
27
27
 
28
28
  formatter.send(:extend, ::LogStashLogger::TaggedLogging::Formatter)
29
29
  formatter
@@ -47,5 +47,13 @@ module LogStashLogger
47
47
  def self.custom_formatter_class?(formatter_type)
48
48
  formatter_type.is_a?(Class) && formatter_type.method_defined?(:call)
49
49
  end
50
+
51
+ def self.initialize_custom_formatter_klass(formatter_klass, customize_event)
52
+ if formatter_klass.instance_method(:initialize).parameters.include?([:key, :customize_event])
53
+ formatter_klass.new(customize_event: customize_event)
54
+ else
55
+ formatter_klass.new
56
+ end
57
+ end
50
58
  end
51
59
  end
@@ -26,31 +26,19 @@ module LogStashLogger
26
26
  end
27
27
  end
28
28
 
29
- protected
30
29
 
31
30
  def self.extract_opts(*args)
32
- args.flatten!
33
-
34
- if args.length > 1
35
- if args.all?{|arg| arg.is_a?(Hash)}
36
- # Deprecated array of hashes
37
- warn "[LogStashLogger] Passing an array of hashes to the constructor is deprecated. Please replace with an options hash: { type: :multi_delegator, outputs: [...] }"
38
- { type: :multi_delegator, outputs: args }
39
- else
40
- # Deprecated host/port/type constructor
41
- warn "[LogStashLogger] The (host, port, type) constructor is deprecated. Please use an options hash instead."
42
- host, port, type = *args
43
- { host: host, port: port, type: type }
44
- end
45
- elsif Hash === args[0]
31
+ if args.length == 1 && args[0].is_a?(Hash)
46
32
  args[0]
47
33
  else
48
- fail ArgumentError, "Invalid LogStashLogger options"
34
+ fail ArgumentError, "Invalid LogStashLogger options. Expected a single options hash."
49
35
  end
50
36
  end
51
37
 
52
38
  def self.build_logger(opts)
53
- formatter = Formatter.new(opts.delete(:formatter), customize_event: opts.delete(:customize_event))
39
+ formatter = Formatter.new(opts.delete(:formatter),
40
+ customize_event: opts.delete(:customize_event),
41
+ error_logger: opts.fetch(:error_logger, LogStashLogger.configuration.default_error_logger))
54
42
 
55
43
  logger_type = opts[:type].to_s.to_sym
56
44
  logger = case logger_type
@@ -60,13 +48,12 @@ module LogStashLogger
60
48
  build_syslog_logger(opts)
61
49
  else
62
50
  build_default_logger(opts)
63
- end
51
+ end
64
52
 
65
53
  logger.formatter = formatter if formatter
66
54
  logger
67
55
  end
68
56
 
69
- private
70
57
 
71
58
  def self.build_default_logger(opts)
72
59
  logger_class = opts.delete(:logger_class) || ::Logger
@@ -70,7 +70,7 @@ module LogStashLogger
70
70
  def method_missing(name, *args, &block)
71
71
  @loggers.each do |logger|
72
72
  if logger.respond_to?(name)
73
- logger.send(name, args, &block)
73
+ logger.send(name, *args, &block)
74
74
  end
75
75
  end
76
76
  end
@@ -101,6 +101,7 @@ module LogStashLogger
101
101
  logger.add(severity, message, progname, &block)
102
102
  end
103
103
  end
104
+ alias log add
104
105
 
105
106
  def <<(msg)
106
107
  @loggers.each do |logger|
@@ -25,7 +25,7 @@ module LogStashLogger
25
25
  # Convert logger options to OrderedOptions if regular Hash
26
26
  logger_options = ActiveSupport::OrderedOptions.new.merge(logger_options)
27
27
 
28
- if parsed_uri_options = LogStashLogger::Device.parse_uri_config(logger_options)
28
+ if (parsed_uri_options = LogStashLogger::Device.parse_uri_config(logger_options))
29
29
  logger_options.delete(:uri)
30
30
  logger_options.merge!(parsed_uri_options)
31
31
  end
@@ -19,7 +19,9 @@ module LogStashLogger
19
19
  end
20
20
 
21
21
  def push_tags(*tags)
22
- tags.flatten.reject{ |t| t.nil? || t.empty? }.tap do |new_tags|
22
+ non_empty_tags = tags.flatten.compact.reject { |t| t.respond_to?(:empty?) && t.empty? }
23
+
24
+ non_empty_tags.tap do |new_tags|
23
25
  current_tags.concat new_tags
24
26
  end
25
27
  end
@@ -1,3 +1,3 @@
1
1
  module LogStashLogger
2
- VERSION = "0.26.1"
2
+ VERSION = "1.1.0"
3
3
  end
@@ -22,13 +22,11 @@ Gem::Specification.new do |gem|
22
22
 
23
23
  gem.add_development_dependency 'rails'
24
24
  gem.add_development_dependency 'redis'
25
- gem.add_development_dependency 'poseidon'
25
+ gem.add_development_dependency 'ruby-kafka'
26
26
  gem.add_development_dependency 'aws-sdk-kinesis'
27
27
  gem.add_development_dependency 'aws-sdk-firehose'
28
28
 
29
- if defined?(JRUBY_VERSION)
30
- gem.add_development_dependency 'SyslogLogger'
31
- end
29
+ gem.add_development_dependency 'syslog', '>= 0.4'
32
30
 
33
31
  gem.add_development_dependency 'rspec', '>= 3'
34
32
  gem.add_development_dependency 'rake'
@@ -36,4 +34,10 @@ Gem::Specification.new do |gem|
36
34
  gem.add_development_dependency 'wwtd'
37
35
  gem.add_development_dependency 'appraisal'
38
36
  gem.add_development_dependency 'rubocop'
37
+ gem.add_development_dependency 'rubocop-performance'
38
+ gem.add_development_dependency 'rubocop-rails'
39
+ gem.add_development_dependency 'rubocop-rake'
40
+ gem.add_development_dependency 'rubocop-rspec'
41
+
42
+ gem.required_ruby_version = '>= 3.3'
39
43
  end
@@ -0,0 +1,219 @@
1
+ require 'logstash-logger'
2
+ require 'logger'
3
+
4
+ # Stub AWS clients that record every request and can be told to fail.
5
+ class FakeKinesisClient
6
+ attr_reader :requests
7
+
8
+ def initialize(fail_requests: [])
9
+ @requests = []
10
+ @fail_requests = fail_requests
11
+ end
12
+
13
+ def put_records(params)
14
+ @requests << params[:records].map { |r| r[:data] }
15
+ raise Seahorse::Client::NetworkingError.new(StandardError.new('connection reset')) if @fail_requests.include?(@requests.size)
16
+ entries = params[:records].map { Aws::Kinesis::Types::PutRecordsResultEntry.new(sequence_number: '1', shard_id: 's') }
17
+ Aws::Kinesis::Types::PutRecordsOutput.new(failed_record_count: 0, records: entries)
18
+ end
19
+ end
20
+
21
+ class FakeFirehoseClient
22
+ attr_reader :requests
23
+
24
+ def initialize
25
+ @requests = []
26
+ end
27
+
28
+ def put_record_batch(params)
29
+ @requests << params[:records].map { |r| r[:data] }
30
+ entries = params[:records].map { Aws::Firehose::Types::PutRecordBatchResponseEntry.new(record_id: '1') }
31
+ Aws::Firehose::Types::PutRecordBatchOutput.new(failed_put_count: 0, request_responses: entries)
32
+ end
33
+ end
34
+
35
+ describe 'AWS outputs and service limits' do
36
+ let(:error_log) { StringIO.new }
37
+ let(:common) do
38
+ { sync: false, buffer_max_interval: 3600, buffer_flush_at_exit: false,
39
+ aws_region: 'us-east-1', error_logger: ::Logger.new(error_log) }
40
+ end
41
+ let(:small) { (1..5).map { |i| %({"n":#{i}}) } }
42
+
43
+ def pending_count(device)
44
+ device.instance_variable_get(:@buffer_state)[:pending_count]
45
+ end
46
+
47
+ describe LogStashLogger::Device::Kinesis do
48
+ let(:client) { FakeKinesisClient.new }
49
+ let(:device) { LogStashLogger::Device.new(common.merge(type: :kinesis)) }
50
+
51
+ before { allow(Aws::Kinesis::Client).to receive(:new) { client } }
52
+
53
+ it 'drops only a record over the per-record limit' do
54
+ device.write_batch(small + ['x' * 1_048_576])
55
+ expect(client.requests).to eq([small])
56
+ expect(error_log.string).to match(/dropping a record of 1048612 bytes: over the 1048576-byte limit/)
57
+ end
58
+
59
+ it 'accepts larger records when aws_max_record_bytes is raised' do
60
+ big = 'x' * 2_000_000
61
+ device = LogStashLogger::Device.new(common.merge(type: :kinesis, aws_max_record_bytes: 10_485_760))
62
+ device.write_batch([big])
63
+ expect(client.requests).to eq([[big]])
64
+ end
65
+
66
+ it 'splits more than 500 records into several requests' do
67
+ messages = (1..600).map(&:to_s)
68
+ device.write_batch(messages)
69
+ expect(client.requests.map(&:size)).to eq([500, 100])
70
+ end
71
+
72
+ it 'splits requests that would go over the request-size limit' do
73
+ messages = Array.new(12) { 'x' * 1_000_000 }
74
+ device.write_batch(messages)
75
+ expect(client.requests.map(&:size)).to eq([10, 2])
76
+ end
77
+
78
+ it 'sends records that are not Strings as text' do
79
+ device.write_batch([123, :sym])
80
+ expect(client.requests).to eq([['123', 'sym']])
81
+ end
82
+
83
+ context 'when a request fails' do
84
+ let(:client) { FakeKinesisClient.new(fail_requests: [1]) }
85
+
86
+ it 'keeps the records for the next flush instead of treating them as delivered' do
87
+ small.each { |m| device.write(m) }
88
+ expect { device.buffer_flush(force: true) }.not_to raise_error
89
+ expect(pending_count(device)).to eq(small.size)
90
+ expect(error_log.string).to include('5 records will be retried on the next flush')
91
+ end
92
+
93
+ it 'drops them when drop_messages_on_flush_error is set' do
94
+ device = LogStashLogger::Device.new(common.merge(type: :kinesis, drop_messages_on_flush_error: true))
95
+ small.each { |m| device.write(m) }
96
+ device.buffer_flush(force: true)
97
+ expect(pending_count(device)).to eq(0)
98
+ expect(error_log.string).to include('dropping 5 records after a failed request')
99
+ end
100
+ end
101
+
102
+ context 'when a later request fails' do
103
+ let(:client) { FakeKinesisClient.new(fail_requests: [2]) }
104
+
105
+ it 'retries only the records that were not sent' do
106
+ device.write_batch((1..600).map(&:to_s))
107
+ expect(pending_count(device)).to eq(100)
108
+ end
109
+ end
110
+
111
+ it 'delivers the rest of a buffered batch around an oversized record' do
112
+ small.each { |m| device.write(m) }
113
+ device.write('x' * 2_000_000)
114
+ device.buffer_flush(force: true)
115
+ expect(client.requests).to eq([small])
116
+ expect(pending_count(device)).to eq(0)
117
+ end
118
+ end
119
+
120
+ describe LogStashLogger::Device::Firehose do
121
+ let(:client) { FakeFirehoseClient.new }
122
+ let(:device) { LogStashLogger::Device.new(common.merge(type: :firehose)) }
123
+
124
+ before { allow(Aws::Firehose::Client).to receive(:new) { client } }
125
+
126
+ it 'drops only a record over the 1,000 KiB limit' do
127
+ device.write_batch(small + ['x' * 1_024_001])
128
+ expect(client.requests).to eq([small])
129
+ end
130
+
131
+ it 'splits requests at 4 MB' do
132
+ device.write_batch(Array.new(5) { 'x' * 1_000_000 })
133
+ expect(client.requests.map(&:size)).to eq([4, 1])
134
+ end
135
+ end
136
+ end
137
+
138
+ # Throttles every record of the first `throttled` requests, then accepts all.
139
+ class ThrottlingKinesisClient
140
+ attr_reader :requests
141
+
142
+ def initialize(throttled:, code: 'ProvisionedThroughputExceededException')
143
+ @requests = []
144
+ @throttled = throttled
145
+ @code = code
146
+ end
147
+
148
+ def put_records(params)
149
+ @requests << params[:records].map { |r| r[:data] }
150
+ if @requests.size <= @throttled
151
+ entries = params[:records].map { Aws::Kinesis::Types::PutRecordsResultEntry.new(error_code: @code, error_message: 'slow down') }
152
+ Aws::Kinesis::Types::PutRecordsOutput.new(failed_record_count: entries.size, records: entries)
153
+ else
154
+ entries = params[:records].map { Aws::Kinesis::Types::PutRecordsResultEntry.new(sequence_number: '1', shard_id: 's') }
155
+ Aws::Kinesis::Types::PutRecordsOutput.new(failed_record_count: 0, records: entries)
156
+ end
157
+ end
158
+
159
+ def delivered
160
+ @requests.drop(@throttled).flatten
161
+ end
162
+ end
163
+
164
+ describe LogStashLogger::Device::Kinesis, 'when records are throttled' do
165
+ let(:error_log) { StringIO.new }
166
+ let(:client) { ThrottlingKinesisClient.new(throttled: 1) }
167
+ let(:records) { (1..50).map(&:to_s) }
168
+
169
+ before { allow(Aws::Kinesis::Client).to receive(:new) { client } }
170
+
171
+ def device(**opts)
172
+ LogStashLogger::Device.new({ type: :kinesis, aws_region: 'us-east-1', sync: false, buffer_max_interval: 0.2,
173
+ buffer_flush_at_exit: false, error_logger: ::Logger.new(error_log) }.merge(opts))
174
+ end
175
+
176
+ # These flushes run on the buffer's background timer thread, so poll until
177
+ # the block returns true, giving up after `timeout` seconds. The example's
178
+ # own expectations then report what went wrong if it never does.
179
+ def wait_until(timeout: 5)
180
+ deadline = Time.now + timeout
181
+ sleep 0.05 until yield || Time.now > deadline
182
+ end
183
+
184
+ it 'retries a full throttled batch on the timer thread without losing it or stopping the timer' do
185
+ dev = device
186
+ timer = dev.instance_variable_get(:@flush_timer_thread)
187
+ records.each { |r| dev.write(r) }
188
+
189
+ wait_until { client.delivered.size == records.size }
190
+
191
+ expect(client.delivered).to match_array(records)
192
+ expect(dev.instance_variable_get(:@buffer_state)[:pending_count]).to eq(0)
193
+ expect(dev.instance_variable_get(:@buffer_state)[:outgoing_count]).to eq(0)
194
+ expect(timer).to be_alive
195
+ expect(error_log.string).to include('50 records will be retried on the next flush (ProvisionedThroughputExceededException: 50)')
196
+ expect(error_log.string).not_to include('Buffer Full')
197
+ end
198
+
199
+ it 'does not block logging when drop_messages_on_full_buffer is false' do
200
+ dev = device(drop_messages_on_full_buffer: false)
201
+ records.each { |r| dev.write(r) }
202
+ wait_until { client.delivered.size == records.size }
203
+
204
+ writer = Thread.new { dev.write('after') }
205
+ expect(writer.join(2)).not_to be_nil
206
+ expect(client.delivered).to match_array(records)
207
+ end
208
+
209
+ context 'with InternalFailure' do
210
+ let(:client) { ThrottlingKinesisClient.new(throttled: 1, code: 'InternalFailure') }
211
+
212
+ it 'retries the records' do
213
+ dev = device
214
+ records.first(3).each { |r| dev.write(r) }
215
+ wait_until { client.delivered.size == 3 }
216
+ expect(client.delivered).to match_array(records.first(3))
217
+ end
218
+ end
219
+ end
@@ -72,3 +72,54 @@ describe LogStashLogger::Device::Connectable do
72
72
  end
73
73
  end
74
74
  end
75
+
76
+ describe LogStashLogger::Device::Connectable, 'max_record_bytes' do
77
+ let(:error_log) { StringIO.new }
78
+
79
+ def device(**opts)
80
+ LogStashLogger::Device.new({ type: :udp, port: 5228, sync: false, buffer_max_interval: 3600,
81
+ buffer_flush_at_exit: false, error_logger: ::Logger.new(error_log) }.merge(opts))
82
+ end
83
+
84
+ def pending(dev)
85
+ dev.instance_variable_get(:@buffer_state)[:pending_items][nil]
86
+ end
87
+
88
+ it 'is off by default' do
89
+ dev = device
90
+ dev.write('x' * 2_000_000)
91
+ expect(pending(dev).size).to eq(1)
92
+ end
93
+
94
+ it 'drops only records over the limit, logging the size but not the content' do
95
+ dev = device(max_record_bytes: 100)
96
+ dev.write('small')
97
+ dev.write('secret' * 50)
98
+ expect(pending(dev)).to eq(['small'])
99
+ expect(error_log.string).to include('dropping a record of 300 bytes: over max_record_bytes (100)')
100
+ expect(error_log.string).not_to include('secret')
101
+ end
102
+
103
+ it 'measures records that are not Strings' do
104
+ dev = device(max_record_bytes: 2)
105
+ dev.write(123)
106
+ expect(pending(dev)).to be_empty
107
+ end
108
+
109
+ it 'accepts a String value, as URI query options arrive' do
110
+ dev = LogStashLogger::Device.new(uri: 'udp://localhost:5228?max_record_bytes=100', buffer_flush_at_exit: false,
111
+ error_logger: ::Logger.new(error_log))
112
+ dev.write('x' * 101)
113
+ expect(pending(dev)).to be_empty
114
+ end
115
+
116
+ it 'rejects a value that is not a positive number' do
117
+ expect { device(max_record_bytes: 'lots') }.to raise_error(ArgumentError)
118
+ expect { device(max_record_bytes: 0) }.to raise_error(ArgumentError, /positive/)
119
+ end
120
+
121
+ it 'is not forwarded to the Redis client' do
122
+ expect(::Redis).to receive(:new).with(hash_excluding(:max_record_bytes)).and_return(double('redis'))
123
+ LogStashLogger::Device.new(type: :redis, max_record_bytes: 100, buffer_flush_at_exit: false).connect
124
+ end
125
+ end
@@ -7,6 +7,71 @@ describe LogStashLogger::Device::File do
7
7
  expect(file_device.to_io).to be_a ::File
8
8
  end
9
9
 
10
+ context "with shift options" do
11
+ let(:shift_file) do
12
+ temp = Tempfile.new('logstash_shift')
13
+ temp.close
14
+ temp
15
+ end
16
+
17
+ let(:shift_device) do
18
+ LogStashLogger::Device.new(
19
+ type: :file,
20
+ path: shift_file.path,
21
+ shift_age: 1,
22
+ shift_size: 1
23
+ )
24
+ end
25
+
26
+ let(:period_file) do
27
+ temp = Tempfile.new('logstash_period_shift')
28
+ temp.close
29
+ temp
30
+ end
31
+
32
+ let(:period_device) do
33
+ LogStashLogger::Device.new(
34
+ type: :file,
35
+ path: period_file.path,
36
+ shift_age: 'daily',
37
+ shift_period_suffix: '%Y-%m-%d'
38
+ )
39
+ end
40
+
41
+ after do
42
+ shift_device.close
43
+ ::Dir.glob("#{shift_file.path}*").each do |path|
44
+ ::File.delete(path) if ::File.exist?(path)
45
+ end
46
+
47
+ period_device.close
48
+ ::Dir.glob("#{period_file.path}*").each do |path|
49
+ ::File.delete(path) if ::File.exist?(path)
50
+ end
51
+ end
52
+
53
+ it "wraps a Logger::LogDevice for rotation" do
54
+ expect(shift_device.io).to be_a ::Logger::LogDevice
55
+ expect(shift_device.to_io).to be_a ::File
56
+ end
57
+
58
+ it "rotates when shift_size is exceeded" do
59
+ shift_device.write("a" * 10)
60
+ shift_device.write("b" * 10)
61
+ shift_device.close
62
+
63
+ expect(::File.exist?("#{shift_file.path}.0")).to be(true)
64
+ end
65
+
66
+ it "uses shift_period_suffix for time-based rotation" do
67
+ period_device.write("a")
68
+ period_device.io.send(:shift_log_period, Time.new(2026, 1, 22))
69
+ period_device.close
70
+
71
+ expect(::File.exist?("#{period_file.path}.2026-01-22")).to be(true)
72
+ end
73
+ end
74
+
10
75
  context "when path is not specified" do
11
76
  it "raises an exception" do
12
77
  expect { described_class.new }.to raise_error(ArgumentError)
@@ -30,9 +30,12 @@ describe LogStashLogger::Device::Firehose do
30
30
  response.request_responses = [failed_record]
31
31
 
32
32
  expect(client).to receive(:put_record_batch) { response }
33
- expect(firehose_device).to receive(:write).with("foo")
33
+ expect(firehose_device).not_to receive(:write)
34
34
 
35
35
  firehose_device.write_one "foo"
36
+ state = firehose_device.instance_variable_get(:@buffer_state)
37
+ expect(state[:pending_items][nil]).to eq(["foo"])
38
+ expect(state[:pending_count]).to eq(1)
36
39
  end
37
40
 
38
41
  it "defaults the Firehose stream to logstash" do