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
@@ -0,0 +1,104 @@
1
+ require 'uri'
2
+ require 'net/http'
3
+
4
+ module LogStashLogger
5
+ module Device
6
+
7
+ # Rudimentary write to Logstash HTTP Input relying on buffering
8
+ # rather than persistent HTTP connections for efficiency.
9
+ class HTTP < Connectable
10
+
11
+ # Request body formats, with the Content-Type sent by default for each.
12
+ FORMATS = {
13
+ json_array: "application/json",
14
+ ndjson: "application/x-ndjson",
15
+ }.freeze
16
+
17
+ DEFAULT_FORMAT = :json_array
18
+
19
+ # Statuses that reject the content of the request. Sending the same
20
+ # records again can never succeed, so the rejected records are found and
21
+ # dropped. Every other error status is retried.
22
+ CONTENT_REJECTED_STATUSES = [
23
+ 400, # Bad Request: the body is malformed
24
+ 413, # Content Too Large: the body is over the collector's size limit
25
+ 415, # Unsupported Media Type: the collector does not accept this content type
26
+ 422, # Unprocessable Content: the body is well-formed but cannot be processed
27
+ ].freeze
28
+
29
+ def initialize(opts)
30
+ super
31
+ @url = URI(opts[:url])
32
+ @format = (opts[:format] || DEFAULT_FORMAT).to_sym
33
+ unless FORMATS.key?(@format)
34
+ raise ArgumentError, "Unknown HTTP body format: #{@format.inspect} (expected one of #{FORMATS.keys.map(&:inspect).join(', ')})"
35
+ end
36
+ @content_type = opts[:content_type] || FORMATS[@format]
37
+ end
38
+
39
+ def connect
40
+ # no-op
41
+ end
42
+
43
+ def write_one(message)
44
+ write_batch([message])
45
+ end
46
+
47
+ def write_batch(messages, _group = nil)
48
+ messages = messages.map(&:to_s)
49
+ resp = post(messages)
50
+ return if resp.is_a?(Net::HTTPSuccess)
51
+ raise upload_error(resp) unless content_rejected?(resp)
52
+ deliver_one_at_a_time(messages, resp)
53
+ end
54
+
55
+ private
56
+
57
+ def post(messages)
58
+ body = @format == :ndjson ? ndjson_body(messages) : json_array_body(messages)
59
+ Net::HTTP.post @url, body, {"Content-Type" => @content_type}
60
+ end
61
+
62
+ # The collector rejected the content of the batch. Send the records one
63
+ # at a time and drop only the ones that are still rejected, so one bad
64
+ # record does not hold back, or take down, the rest.
65
+ def deliver_one_at_a_time(messages, batch_resp)
66
+ return drop(messages.first, batch_resp) if messages.size == 1
67
+
68
+ messages.each do |message|
69
+ resp = post([message])
70
+ next if resp.is_a?(Net::HTTPSuccess)
71
+
72
+ if content_rejected?(resp)
73
+ drop(message, resp)
74
+ else
75
+ raise upload_error(resp)
76
+ end
77
+ end
78
+ end
79
+
80
+ def content_rejected?(resp)
81
+ CONTENT_REJECTED_STATUSES.include?(resp.code.to_i)
82
+ end
83
+
84
+ def drop(message, resp)
85
+ log_warning("dropping a record of #{message.bytesize} bytes that the collector rejected: HTTP #{resp.code} #{resp.message}")
86
+ end
87
+
88
+ def upload_error(resp)
89
+ RuntimeError.new("HTTP log upload failed: #{resp.code} #{resp.message}").tap { |e| log_error(e) }
90
+ end
91
+
92
+ # Logstash HTTP input expects a JSON array instead of lines of JSON
93
+ def json_array_body(messages)
94
+ "[#{messages.join(',')}]"
95
+ end
96
+
97
+ # Newline-delimited JSON: one event per line, each ending with a newline
98
+ def ndjson_body(messages)
99
+ messages.map { |m| m.end_with?("\n") ? m : "#{m}\n" }.join
100
+ end
101
+
102
+ end
103
+ end
104
+ end
@@ -1,56 +1,200 @@
1
- require 'poseidon'
2
-
3
1
  module LogStashLogger
4
2
  module Device
5
3
  class Kafka < Connectable
4
+ class TLSConfiguration
5
+ attr_reader :ssl_ca_cert, :ssl_client_cert, :ssl_client_cert_key
6
+
7
+ def initialize(opts = {})
8
+ @ssl_ca_cert = opts[:ssl_ca_cert]
9
+ @ssl_client_cert = opts[:ssl_client_cert]
10
+ @ssl_client_cert_key = opts[:ssl_client_cert_key]
11
+ end
12
+
13
+ def cert_bundle
14
+ @cert_bundle ||= all_cert_params? ? cert_params_as_hash : {}
15
+ end
16
+
17
+ def valid?
18
+ all_cert_params? || no_cert_params?
19
+ end
20
+
21
+ def invalid?
22
+ !valid?
23
+ end
24
+
25
+ private
26
+
27
+ def cert_params_as_hash
28
+ { ssl_ca_cert: @ssl_ca_cert,
29
+ ssl_client_cert: @ssl_client_cert,
30
+ ssl_client_cert_key: @ssl_client_cert_key,
31
+ }
32
+ end
33
+
34
+ def all_cert_params?
35
+ cert_params_as_hash.values.compact.length == valid_cert_params_length
36
+ end
37
+
38
+ def no_cert_params?
39
+ cert_params_as_hash.values.compact.empty?
40
+ end
41
+
42
+ def valid_cert_params_length
43
+ cert_params_as_hash.keys.length
44
+ end
45
+ end
6
46
 
7
- DEFAULT_HOST = 'localhost'
8
- DEFAULT_PORT = 9092
9
- DEFAULT_TOPIC = 'logstash'
10
- DEFAULT_PRODUCER = 'logstash-logger'
11
- DEFAULT_BACKOFF = 1
12
-
13
- attr_accessor :hosts, :topic, :producer, :backoff
14
-
15
- def initialize(opts)
16
- super
17
- host = opts[:host] || DEFAULT_HOST
18
- port = opts[:port] || DEFAULT_PORT
19
- @hosts = opts[:hosts] || host.split(',').map { |h| "#{h}:#{port}" }
20
- @topic = opts[:path] || DEFAULT_TOPIC
21
- @producer = opts[:producer] || DEFAULT_PRODUCER
22
- @backoff = opts[:backoff] || DEFAULT_BACKOFF
47
+ attr_reader :topic, :brokers, :cert_bundle, :kafka_tls_configurator,
48
+ :client_id
49
+
50
+ # Errors that will recur on every retry of the same records. Looked up by
51
+ # name at rescue time, because ruby-kafka is only required in #initialize.
52
+ NOT_RETRYABLE_ERRORS = %w[MessageSizeTooLarge RecordListTooLarge BufferOverflow].freeze
53
+
54
+ def initialize(opts = {}, kafka_tls_configurator = TLSConfiguration)
55
+ require 'ruby-kafka'
56
+ super(opts)
57
+
58
+ opts = normalize_uri_options(opts)
59
+ @client_id = opts[:client_id]
60
+ @topic = normalize_topic(opts[:topic])
23
61
  @buffer_group = @topic
62
+ @kafka_tls_configurator = kafka_tls_configurator
63
+ @brokers = make_brokers_array(opts[:brokers])
64
+ raise_no_brokers_set! if @brokers.empty?
65
+ make_cert_bundle(opts)
66
+ end
67
+
68
+ def connection
69
+ @io ||= ::Kafka.new(**kafka_client_connection_hash)
24
70
  end
25
71
 
26
72
  def connect
27
- @io = ::Poseidon::Producer.new(@hosts, @producer)
28
- end
29
-
30
- def with_connection
31
- connect unless connected?
32
- yield
33
- rescue ::Poseidon::Errors::ChecksumError, Poseidon::Errors::UnableToFetchMetadata => e
34
- log_error(e)
35
- log_warning("reconnect/retry")
36
- sleep backoff if backoff
37
- reconnect
38
- retry
39
- rescue => e
40
- log_error(e)
41
- log_warning("giving up")
42
- close(flush: false)
73
+ @io = connection
43
74
  end
44
75
 
45
- def write_batch(messages, topic = nil)
76
+ def write_one(message, topic=nil)
46
77
  topic ||= @topic
78
+ write_messages_to_broker_and_deliver do |producer|
79
+ producer.produce(message, topic: topic)
80
+ end
81
+ end
82
+
83
+ def write_batch(messages, topic=nil)
84
+ topic ||= @topic
85
+ write_messages_to_broker_and_deliver do |producer|
86
+ messages.each {|msg| producer.produce(msg, topic: topic) }
87
+ end
88
+ rescue => e
89
+ raise if retryable_error?(e)
90
+ deliver_one_at_a_time(messages, topic)
91
+ end
92
+
93
+ private
94
+
95
+ # One record the broker or client will never accept fails the whole batch.
96
+ # Send the records one at a time and drop only the ones that are still
97
+ # rejected. Retryable errors raise, so the buffer retries the batch.
98
+ def deliver_one_at_a_time(messages, topic)
99
+ messages.each do |message|
100
+ begin
101
+ write_one(message, topic)
102
+ rescue => e
103
+ raise if retryable_error?(e)
104
+ log_warning("dropping a record of #{message.to_s.bytesize} bytes that Kafka rejected: #{e.class}")
105
+ end
106
+ end
107
+ end
108
+
109
+ def retryable_error?(error)
110
+ NOT_RETRYABLE_ERRORS.none? do |name|
111
+ ::Kafka.const_defined?(name) && error.is_a?(::Kafka.const_get(name))
112
+ end
113
+ end
114
+
115
+ def write_messages_to_broker_and_deliver(&block)
47
116
  with_connection do
48
- @io.send_messages messages.map { |message| Poseidon::MessageToSend.new(topic, message) }
117
+ kproducer = producer
118
+ block.call(kproducer) if block_given?
119
+ kproducer.deliver_messages
120
+ end
121
+ end
122
+
123
+ def close!
124
+ begin
125
+ if @producer
126
+ if @producer.respond_to?(:shutdown)
127
+ @producer.shutdown
128
+ elsif @producer.respond_to?(:close)
129
+ @producer.close
130
+ end
131
+ end
132
+ ensure
133
+ @producer = nil
134
+ super
135
+ end
136
+ end
137
+
138
+ def kafka_client_connection_hash
139
+ { seed_brokers: @brokers,
140
+ client_id: @client_id,
141
+ }.merge(@cert_bundle)
142
+ end
143
+
144
+ def producer
145
+ @producer ||= (connected? ? @io : connection).producer
146
+ end
147
+
148
+ def raise_no_topic_set!
149
+ fail ArgumentError, "a topic must be configured"
150
+ end
151
+
152
+ def raise_no_brokers_set!
153
+ fail ArgumentError, "brokers must be configured"
154
+ end
155
+
156
+ def normalize_topic(topic)
157
+ normalized = topic.to_s.strip
158
+ return raise_no_topic_set! if normalized.empty?
159
+ normalized
160
+ end
161
+
162
+ def make_brokers_array(opt)
163
+ brokers =
164
+ case opt
165
+ when Array
166
+ opt.flatten
167
+ when String
168
+ opt.split(/\s+/)
169
+ else
170
+ []
171
+ end
172
+ brokers.compact.map(&:to_s).reject(&:empty?)
173
+ end
174
+
175
+ def normalize_uri_options(opts)
176
+ normalized = opts.dup
177
+ if normalized[:brokers].nil?
178
+ if normalized[:hosts]
179
+ normalized[:brokers] = normalized[:hosts]
180
+ elsif normalized[:host]
181
+ broker = normalized[:host]
182
+ broker = "#{broker}:#{normalized[:port]}" if normalized[:port]
183
+ normalized[:brokers] = broker
184
+ end
185
+ end
186
+ if normalized[:topic].nil? && normalized[:path]
187
+ normalized[:topic] = normalized[:path].to_s.sub(%r{\A/}, '')
49
188
  end
189
+ normalized
50
190
  end
51
191
 
52
- def write_one(message, topic = nil)
53
- write_batch([message], topic)
192
+ def make_cert_bundle(opts)
193
+ tls_conf = kafka_tls_configurator.new(opts)
194
+ if tls_conf.invalid?
195
+ fail ArgumentError, "all ssl parameters (ssl_ca_cert, ssl_client_cert and ssl_client_cert_key) are required or do use any of them to not use TLS"
196
+ end
197
+ @cert_bundle ||= tls_conf.cert_bundle
54
198
  end
55
199
  end
56
200
  end
@@ -14,9 +14,18 @@ module LogStashLogger
14
14
  "ServiceUnavailable",
15
15
  "Throttling",
16
16
  "RequestExpired",
17
- "ProvisionedThroughputExceededException"
17
+ "ProvisionedThroughputExceededException",
18
+ "InternalFailure"
18
19
  ].freeze
19
20
 
21
+ # https://docs.aws.amazon.com/kinesis/latest/APIReference/API_PutRecords.html
22
+ # Records count the partition key. 1 MiB is the default and smallest
23
+ # per-stream maximum; raise it with aws_max_record_bytes if the stream
24
+ # is configured for larger records.
25
+ def self.max_records; 500; end
26
+ def self.max_record_bytes; 1_048_576; end
27
+ def self.max_request_bytes; 10_485_760; end
28
+
20
29
  def transform_message(message)
21
30
  {
22
31
  data: message,
@@ -13,6 +13,7 @@ module LogStashLogger
13
13
  @buffer_group = @list
14
14
 
15
15
  normalize_path(opts)
16
+ delete_unknown_keywords(opts)
16
17
 
17
18
  @redis_options = opts
18
19
  end
@@ -59,11 +60,17 @@ module LogStashLogger
59
60
  def normalize_path(opts)
60
61
  path = opts.fetch(:path, nil)
61
62
  if path
62
- opts[:db] = path.gsub("/", "").to_i unless path.empty?
63
+ opts[:db] = path.delete("/").to_i unless path.empty?
63
64
  opts.delete(:path)
64
65
  end
65
66
  end
66
67
 
68
+ def delete_unknown_keywords(opts)
69
+ # due to restrictions in redis client version >= 5
70
+ # Continous error is being logged for unknown keywords, at present there is only one i.e. :sync
71
+ # to prevent that adding following line :)
72
+ opts.delete(:sync)
73
+ end
67
74
  end
68
75
  end
69
76
  end
@@ -1,4 +1,5 @@
1
1
  require 'openssl'
2
+ require 'ipaddr'
2
3
 
3
4
  module LogStashLogger
4
5
  module Device
@@ -12,16 +13,17 @@ module LogStashLogger
12
13
  @ssl_context = opts[:ssl_context]
13
14
  @use_ssl = !!(@ssl_certificate || opts[:ssl_context])
14
15
  @use_ssl = opts[:ssl_enable] if opts.has_key? :ssl_enable
15
- if opts.has_key?(:use_ssl)
16
- @use_ssl = opts[:use_ssl]
17
- warn "[LogStashLogger] The use_ssl option is deprecated. Use ssl_enable instead."
18
- end
19
16
  @verify_hostname = opts.fetch(:verify_hostname, true)
17
+ validate_ssl_certificate! if use_ssl? && @ssl_certificate && !@ssl_context
20
18
  end
21
19
 
20
+ # The TLS context used for the connection. Without an operator-supplied
21
+ # :ssl_context, the server certificate is always verified: against the
22
+ # :ssl_certificate file when one is given, otherwise against the system
23
+ # trust store.
22
24
  def ssl_context
23
25
  return unless use_ssl?
24
- @ssl_context || certificate_context
26
+ @ssl_context || certificate_context || default_context
25
27
  end
26
28
 
27
29
  def use_ssl?
@@ -41,7 +43,7 @@ module LogStashLogger
41
43
  ssl_io
42
44
  else
43
45
  tcp_io
44
- end
46
+ end
45
47
  end
46
48
 
47
49
  protected
@@ -53,20 +55,60 @@ module LogStashLogger
53
55
  end
54
56
 
55
57
  def ssl_io
56
- ssl_context ?
57
- OpenSSL::SSL::SSLSocket.new(tcp_io, ssl_context) :
58
- OpenSSL::SSL::SSLSocket.new(tcp_io)
58
+ socket = tcp_io
59
+ begin
60
+ OpenSSL::SSL::SSLSocket.new(socket, ssl_context).tap do |ssl|
61
+ # Closing the TLS socket also closes the TCP socket underneath it
62
+ ssl.sync_close = true
63
+ # SNI is only sent for DNS names, never for IP addresses
64
+ ssl.hostname = hostname unless ip_address?(hostname)
65
+ end
66
+ rescue StandardError
67
+ socket.close rescue nil
68
+ raise
69
+ end
59
70
  end
60
71
 
72
+ # Trusts the certificate (or CA bundle) in the :ssl_certificate file, in
73
+ # addition to the system trust store.
61
74
  def certificate_context
62
75
  return unless @ssl_certificate
63
- @certificate_context ||= OpenSSL::SSL::SSLContext.new.tap do |ctx|
64
- ctx.set_params(cert: @ssl_certificate)
76
+ @certificate_context ||= verifying_context(ca_file: @ssl_certificate)
77
+ end
78
+
79
+ # Used when TLS is enabled without an :ssl_context or :ssl_certificate.
80
+ def default_context
81
+ @default_context ||= verifying_context
82
+ end
83
+
84
+ def verifying_context(params = {})
85
+ OpenSSL::SSL::SSLContext.new.tap do |ctx|
86
+ # VERIFY_PEER, hostname verification and the system certificate store
87
+ ctx.set_params(params)
88
+ # Honour an explicit verify_hostname: false
89
+ ctx.verify_hostname = !!@verify_hostname
90
+ end
91
+ end
92
+
93
+ def validate_ssl_certificate!
94
+ path = @ssl_certificate.to_s
95
+ unless ::File.file?(path) && ::File.readable?(path)
96
+ raise ArgumentError, "ssl_certificate must be a readable certificate file: #{path.inspect}"
65
97
  end
98
+ OpenSSL::X509::Certificate.new(::File.read(path))
99
+ rescue OpenSSL::X509::CertificateError => e
100
+ raise ArgumentError, "ssl_certificate #{path.inspect} is not a PEM or DER certificate: #{e.message}"
101
+ end
102
+
103
+ def ip_address?(name)
104
+ IPAddr.new(name.to_s)
105
+ true
106
+ rescue IPAddr::Error
107
+ false
66
108
  end
67
109
 
68
110
  def verify_hostname?
69
- return false unless ssl_context
111
+ return false unless use_ssl?
70
112
  !! @verify_hostname
71
113
  end
72
114
 
@@ -20,6 +20,7 @@ module LogStashLogger
20
20
  autoload :Stderr, 'logstash-logger/device/stderr'
21
21
  autoload :Balancer, 'logstash-logger/device/balancer'
22
22
  autoload :MultiDelegator, 'logstash-logger/device/multi_delegator'
23
+ autoload :HTTP, 'logstash-logger/device/http'
23
24
 
24
25
  def self.new(opts)
25
26
  opts = opts.dup
@@ -27,7 +28,7 @@ module LogStashLogger
27
28
  end
28
29
 
29
30
  def self.build_device(opts)
30
- if parsed_uri_opts = parse_uri_config(opts)
31
+ if (parsed_uri_opts = parse_uri_config(opts))
31
32
  opts.delete(:uri)
32
33
  opts.merge!(parsed_uri_opts)
33
34
  end
@@ -38,10 +39,30 @@ module LogStashLogger
38
39
  end
39
40
 
40
41
  def self.parse_uri_config(opts)
41
- if uri = opts[:uri]
42
+ if (uri = opts[:uri])
42
43
  require 'uri'
43
44
  parsed = ::URI.parse(uri)
44
45
  {type: parsed.scheme, host: parsed.host, port: parsed.port, path: parsed.path}
46
+ .merge(parse_uri_query(parsed.query))
47
+ end
48
+ end
49
+
50
+ def self.parse_uri_query(query)
51
+ return {} unless query && !query.empty?
52
+ ::URI.decode_www_form(query).each_with_object({}) do |(key, value), acc|
53
+ next unless key
54
+ key = key.to_s.strip
55
+ next if key.empty?
56
+ acc[key.to_sym] = cast_uri_value(key, value)
57
+ end
58
+ end
59
+
60
+ def self.cast_uri_value(key, value)
61
+ case key
62
+ when 'buffer_max_items'
63
+ value.to_i
64
+ else
65
+ value
45
66
  end
46
67
  end
47
68
 
@@ -60,6 +81,7 @@ module LogStashLogger
60
81
  when :stderr then Stderr
61
82
  when :multi_delegator then MultiDelegator
62
83
  when :balancer then Balancer
84
+ when :http then HTTP
63
85
  else fail ArgumentError, 'Invalid device type'
64
86
  end
65
87
  end
@@ -4,23 +4,60 @@ require 'time'
4
4
 
5
5
  module LogStashLogger
6
6
  module Formatter
7
- HOST = ::Socket.gethostname
7
+ HOST = {
8
+ 'hostname' => ::Socket.gethostname,
9
+ 'ip' => Socket.ip_address_list.reject(&:ipv4_loopback?).reject(&:ipv6_loopback?).map(&:ip_address)
10
+ }.freeze
8
11
 
9
12
  class Base < ::Logger::Formatter
13
+ FAILED_TO_FORMAT_MSG = 'Failed to format log event'
14
+ FAILED_TO_FORMAT_TAG = '_logstashlogger_format_failure'
15
+ attr_accessor :error_logger
10
16
  include ::LogStashLogger::TaggedLogging::Formatter
11
17
 
12
- def initialize(customize_event: nil)
18
+ def initialize(customize_event: nil, error_logger: LogStashLogger.configuration.default_error_logger)
13
19
  @customize_event = customize_event
20
+ @error_logger = error_logger
14
21
  super()
15
22
  end
16
23
 
17
- def call(severity, time, _progname, message)
24
+ def call(severity, time, progname, message)
18
25
  event = build_event(message, severity, time)
19
- format_event(event) unless event.cancelled?
26
+ frame_event(event, progname) unless event.cancelled?
27
+ rescue StandardError => e
28
+ log_error(e)
29
+ fallback_event(severity, time, progname, e)
20
30
  end
21
31
 
22
32
  private
23
33
 
34
+ # Wraps a built event in this formatter's record framing. Formatters that
35
+ # add a header or trailer override this; the fallback below goes through
36
+ # the same framing.
37
+ def frame_event(event, _progname)
38
+ format_event(event)
39
+ end
40
+
41
+ # When a record cannot be built or serialized, emit a well-formed
42
+ # placeholder in this formatter's own framing instead, so the failure never
43
+ # changes how a collector splits the stream into records. Returns nil
44
+ # (which devices drop) if even the placeholder cannot be formatted.
45
+ def fallback_event(severity, time, progname, error)
46
+ event = LogStash::Event.new(
47
+ '@timestamp'.freeze => time.is_a?(Time) ? time : Time.now,
48
+ 'message'.freeze => FAILED_TO_FORMAT_MSG,
49
+ 'severity'.freeze => severity.to_s,
50
+ 'host'.freeze => HOST,
51
+ 'tags'.freeze => [FAILED_TO_FORMAT_TAG],
52
+ 'error'.freeze => error.class.name
53
+ )
54
+ event.timestamp = event.timestamp.iso8601(3) if event.timestamp.is_a?(Time)
55
+ frame_event(event, progname)
56
+ rescue StandardError => e
57
+ log_error(e)
58
+ nil
59
+ end
60
+
24
61
  def build_event(message, severity, time)
25
62
  data = message
26
63
  if data.is_a?(String) && data.start_with?('{'.freeze)
@@ -31,15 +68,27 @@ module LogStashLogger
31
68
  when LogStash::Event
32
69
  data.clone
33
70
  when Hash
34
- event_data = data.clone
35
- event_data['message'.freeze] = event_data.delete(:message) if event_data.key?(:message)
36
- event_data['tags'.freeze] = event_data.delete(:tags) if event_data.key?(:tags)
37
- event_data['source'.freeze] = event_data.delete(:source) if event_data.key?(:source)
38
- event_data['type'.freeze] = event_data.delete(:type) if event_data.key?(:type)
39
- event_data['@timestamp'.freeze] = time
71
+ event_data = { '@timestamp'.freeze => time }
72
+ data.each do |key, value|
73
+ case key
74
+ when :message, 'message'
75
+ event_data['message'.freeze] = value
76
+ when :tags, 'tags'
77
+ event_data['tags'.freeze] = value
78
+ when :source, 'source'
79
+ event_data['source'.freeze] = value
80
+ when :type, 'type'
81
+ event_data['type'.freeze] = value
82
+ when :'@timestamp', '@timestamp'
83
+ # Keep the logger time unless the value is a usable time
84
+ event_data['@timestamp'.freeze] = usable_time(value) || time
85
+ else
86
+ event_data[key] = value
87
+ end
88
+ end
40
89
  LogStash::Event.new(event_data)
41
90
  else
42
- LogStash::Event.new("message".freeze => msg2str(data), "@timestamp".freeze => time)
91
+ LogStash::Event.new("@timestamp".freeze => time, "message".freeze => msg2str(data))
43
92
  end
44
93
 
45
94
  event['severity'.freeze] ||= severity
@@ -68,6 +117,43 @@ module LogStashLogger
68
117
  def format_event(event)
69
118
  event
70
119
  end
120
+
121
+ # LogStash::Event runs String timestamps through Time.parse, which raises
122
+ # for text that is not a time. Keep every other value exactly as given.
123
+ def usable_time(value)
124
+ return value unless value.is_a?(String)
125
+ Time.parse(value)
126
+ value
127
+ rescue ArgumentError, RangeError
128
+ nil
129
+ end
130
+
131
+ def force_utf8_encoding(event)
132
+ original_message = event.instance_variable_get(:@data)['message']
133
+ event.message = original_message.dup.force_encoding(Encoding::UTF_8).scrub
134
+ event
135
+ end
136
+
137
+ # Check if the message has encoding issues that would cause JSON serialization problems.
138
+ # This is needed because some Ruby implementations (e.g., JRuby) may not raise
139
+ # exceptions during JSON encoding but produce malformed output instead.
140
+ def message_has_encoding_issue?(event)
141
+ message = event.instance_variable_get(:@data)['message']
142
+ return false unless message.is_a?(String)
143
+
144
+ # Check if already valid UTF-8
145
+ return false if message.encoding == Encoding::UTF_8 && message.valid_encoding?
146
+
147
+ # Try to encode to UTF-8 to detect issues
148
+ message.encode(Encoding::UTF_8)
149
+ false
150
+ rescue Encoding::UndefinedConversionError, Encoding::InvalidByteSequenceError
151
+ true
152
+ end
153
+
154
+ def log_error(e)
155
+ error_logger.error "[#{self.class}] #{e.class} - #{e.message}"
156
+ end
71
157
  end
72
158
  end
73
159
  end