logstash-output-newrelic 2.0.0-java

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,281 @@
1
+ # encoding: utf-8
2
+ require "logstash/outputs/base"
3
+ require "logstash/outputs/newrelic_version/version"
4
+ require 'manticore'
5
+ require 'uri'
6
+ require 'zlib'
7
+ require 'json'
8
+ require 'java'
9
+ require 'set'
10
+ require 'stringio'
11
+ require_relative './config/bigdecimal_patch'
12
+ require_relative './exception/error'
13
+
14
+ class LogStash::Outputs::NewRelic < LogStash::Outputs::Base
15
+
16
+ RETRIABLE_CODES = Set[408, 429, 500, 502, 503, 504, 599]
17
+
18
+ MAX_PAYLOAD_SIZE_BYTES = 1_000_000
19
+
20
+ config_name "newrelic"
21
+
22
+ config :api_key, :validate => :password, :required => false
23
+ config :license_key, :validate => :password, :required => false
24
+ config :concurrent_requests, :validate => :number, :default => 1
25
+ config :base_uri, :validate => :string, :default => "https://log-api.newrelic.com/log/v1"
26
+ config :max_retries, :validate => :number, :default => 3
27
+ config :connect_timeout_seconds, :validate => :number, :default => 30
28
+ config :socket_timeout_seconds, :validate => :number, :default => 30
29
+ # Only used for E2E testing
30
+ config :custom_ca_cert, :validate => :string, :required => false
31
+
32
+ public
33
+
34
+ def register
35
+ @end_point = URI.parse(@base_uri)
36
+ if @api_key.nil? && @license_key.nil?
37
+ raise LogStash::ConfigurationError, "Must provide a license key or api key", caller
38
+ end
39
+ @logger.debug("Registering logstash-output-newrelic", :version => LogStash::Outputs::NewRelicVersion::VERSION, :target => @base_uri)
40
+ auth = {
41
+ @api_key.nil? ? 'X-License-Key' : 'X-Insert-Key' =>
42
+ @api_key.nil? ? @license_key.value : @api_key.value
43
+ }
44
+ @header = {
45
+ 'X-Event-Source' => 'logs',
46
+ 'Content-Encoding' => 'gzip',
47
+ 'Content-Type' => 'application/json'
48
+ }.merge(auth).freeze
49
+
50
+ client_options = {
51
+ :pool_max => @concurrent_requests,
52
+ :pool_max_per_route => @concurrent_requests,
53
+ :connect_timeout => @connect_timeout_seconds,
54
+ :socket_timeout => @socket_timeout_seconds
55
+ }
56
+
57
+ # Only configure SSL if using HTTPS
58
+ if @end_point.scheme == 'https'
59
+ client_options[:ssl] = {
60
+ :verify => :default
61
+ }
62
+
63
+ if !@custom_ca_cert.nil?
64
+ # Load the custom CA certificate and add it to the SSL options for the HTTP client
65
+ client_options[:ssl][:ca_file] = @custom_ca_cert
66
+ end
67
+ end
68
+
69
+ @client = Manticore::Client.new(client_options)
70
+
71
+ # We use a semaphore to ensure that at most there are @concurrent_requests inflight Logstash requests being processed
72
+ # by our plugin at the same time. Without this semaphore, given that @executor.submit() is an asynchronous method, it
73
+ # would cause that an unbounded amount of inflight requests may be processed by our plugin. Logstash then believes
74
+ # that our plugin has processed the request, and keeps reading more inflight requests in memory. This causes a memory
75
+ # leak and results in an OutOfMemoryError.
76
+ @executor = java.util.concurrent.Executors.newFixedThreadPool(@concurrent_requests)
77
+ @semaphore = java.util.concurrent.Semaphore.new(@concurrent_requests)
78
+ @shutdown_complete = false
79
+ end
80
+
81
+ # Shutdown hook called by Logstash 5.x and 6.x versions during pipeline shutdown
82
+ def stop
83
+ shutdown
84
+ end
85
+
86
+ # Shutdown hook called by Logstash 7.x+ versions during pipeline shutdown
87
+ def close
88
+ shutdown
89
+ end
90
+
91
+ # Additional shutdown hook for cleanup, called by some Logstash versions
92
+ def teardown
93
+ shutdown
94
+ end
95
+
96
+ # Used by tests so that the test run can complete (background threads prevent JVM exit)
97
+ def shutdown
98
+ return if @shutdown_complete
99
+
100
+ if @executor
101
+ @logger.debug("Draining outstanding New Relic requests")
102
+ @executor.shutdown
103
+ # We want this long enough to not have threading issues
104
+ terminationWaitInSeconds = 10
105
+ terminatedInTime = @executor.awaitTermination(terminationWaitInSeconds, java.util.concurrent.TimeUnit::SECONDS)
106
+ if !terminatedInTime
107
+ raise "Did not shut down within #{terminationWaitInSeconds} seconds"
108
+ end
109
+ end
110
+
111
+ if defined?(@client) && @client
112
+ @logger.debug("Closing New Relic HTTP client")
113
+ @client.close
114
+ end
115
+
116
+ @shutdown_complete = true
117
+ end
118
+
119
+ def time_to_logstash_timestamp(time)
120
+ begin
121
+ LogStash::Timestamp.coerce(time)
122
+ rescue
123
+ nil
124
+ end
125
+ end
126
+
127
+ def to_nr_logs(logstash_events)
128
+ logstash_events.map do |logstash_event|
129
+ event_hash = logstash_event.to_hash
130
+
131
+ nr_log_message_hash = {
132
+ # non-intrinsic attributes get put into 'attributes'
133
+ :attributes => event_hash
134
+ }
135
+
136
+ # intrinsic attributes go at the top level
137
+ if event_hash['message']
138
+ nr_log_message_hash['message'] = event_hash['message']
139
+ nr_log_message_hash[:attributes].delete('message')
140
+ end
141
+ if event_hash['timestamp']
142
+ nr_log_message_hash['timestamp'] = event_hash['timestamp']
143
+ nr_log_message_hash[:attributes].delete('timestamp')
144
+ end
145
+
146
+ nr_log_message_hash
147
+ end
148
+ end
149
+
150
+ def multi_receive(logstash_events)
151
+ if logstash_events.empty?
152
+ return
153
+ end
154
+
155
+ nr_logs = to_nr_logs(logstash_events)
156
+
157
+ @logger.debug("Submitting logs to New Relic", :event_count => nr_logs.length)
158
+
159
+ submit_logs_to_be_sent(nr_logs)
160
+ end
161
+
162
+ def submit_logs_to_be_sent(nr_logs)
163
+ @semaphore.acquire()
164
+ execute = @executor.java_method :submit, [java.lang.Runnable]
165
+ execute.call do
166
+ begin
167
+ package_and_send_recursively(nr_logs)
168
+ ensure
169
+ @semaphore.release()
170
+ end
171
+ end
172
+ end
173
+
174
+ def package_and_send_recursively(nr_logs)
175
+ payload = {
176
+ :common => {
177
+ :attributes => {
178
+ :plugin => {
179
+ :type => 'logstash',
180
+ :version => LogStash::Outputs::NewRelicVersion::VERSION,
181
+ }
182
+ }
183
+ },
184
+ :logs => nr_logs
185
+ }
186
+
187
+ payload_json = [payload].to_json
188
+ compressed_payload = gzip_compress(payload_json, Zlib::DEFAULT_COMPRESSION)
189
+ compressed_size = compressed_payload.bytesize
190
+ log_record_count = nr_logs.length
191
+
192
+ if compressed_size >= MAX_PAYLOAD_SIZE_BYTES && log_record_count == 1
193
+ @logger.error("Can't compress record below required maximum packet size and it will be discarded.")
194
+ elsif compressed_size >= MAX_PAYLOAD_SIZE_BYTES && log_record_count > 1
195
+ @logger.debug("Compressed payload size exceeds maximum packet size, splitting payload", :compressed_size => compressed_size)
196
+ split_index = log_record_count / 2
197
+ @logger.debug("Splitting payload", :split_index => split_index, :first_half => split_index, :second_half => log_record_count - split_index)
198
+ package_and_send_recursively(nr_logs[0...split_index])
199
+ package_and_send_recursively(nr_logs[split_index..-1])
200
+ else
201
+ nr_send(compressed_payload)
202
+ end
203
+ end
204
+
205
+ def handle_response(response)
206
+ if !(200 <= response.code && response.code < 300)
207
+ raise Error::BadResponseCodeError.new(response.code, @base_uri)
208
+ end
209
+ end
210
+
211
+ # Compresses a given payload string using GZIP.
212
+ #
213
+ # @param payload [String] The string payload to be compressed.
214
+ # @param compression_level [Integer] The GZIP compression level to use.
215
+ # @return [String] The GZIP-compressed binary string.
216
+ def gzip_compress(payload, compression_level)
217
+ string_io = StringIO.new
218
+ string_io.set_encoding("BINARY")
219
+ Zlib::GzipWriter.wrap(string_io, compression_level) do |gz|
220
+ gz.write(payload)
221
+ end
222
+ string_io.string
223
+ end
224
+
225
+ def nr_send(payload)
226
+ retries = 0
227
+ retry_duration = 1
228
+
229
+ begin
230
+ @logger.debug("Dispatching payload to New Relic", :endpoint => @base_uri, :payload_size => payload.bytesize)
231
+ response = @client.post(@base_uri, :body => payload, :headers => @header)
232
+ @logger.debug("Received response from New Relic", :code => response.code, :message => response.message)
233
+ handle_response(response)
234
+ if (retries > 0)
235
+ @logger.warn("Successfully sent logs at retry #{retries}")
236
+ else
237
+ @logger.debug("Successfully sent logs to New Relic", :response_code => response.code)
238
+ end
239
+ rescue Error::BadResponseCodeError => e
240
+ @logger.error(e.message)
241
+ if (should_retry(retries) && is_retryable_code(e))
242
+ retries += 1
243
+ sleep(retry_duration)
244
+ retry_duration *= 2
245
+ retry
246
+ end
247
+ rescue => e
248
+ # Stuff that should never happen
249
+ # For all other errors print out full issues
250
+ if (should_retry(retries))
251
+ retries += 1
252
+ @logger.warn(
253
+ "An unknown error occurred sending a bulk request to NewRelic. Retrying...",
254
+ :retries => "attempt #{retries} of #{@max_retries}",
255
+ :error_message => e.message,
256
+ :error_class => e.class.name,
257
+ :backtrace => e.backtrace
258
+ )
259
+ sleep(retry_duration)
260
+ retry_duration *= 2
261
+ retry
262
+ else
263
+ @logger.error(
264
+ "An unknown error occurred sending a bulk request to NewRelic. Maximum of attempts reached, dropping logs.",
265
+ :error_message => e.message,
266
+ :error_class => e.class.name,
267
+ :backtrace => e.backtrace
268
+ )
269
+ end
270
+ end
271
+ end
272
+
273
+ def should_retry(retries)
274
+ retries < @max_retries
275
+ end
276
+
277
+ def is_retryable_code(response_error)
278
+ error_code = response_error.response_code
279
+ RETRIABLE_CODES.include?(error_code)
280
+ end
281
+ end # class LogStash::Outputs::NewRelic
@@ -0,0 +1,7 @@
1
+ module LogStash
2
+ module Outputs
3
+ module NewRelicVersion
4
+ VERSION = "2.0.0"
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,36 @@
1
+ lib = File.expand_path('../lib/', __FILE__)
2
+ $:.unshift lib unless $:.include?(lib)
3
+
4
+ require 'logstash/outputs/newrelic_version/version'
5
+
6
+ Gem::Specification.new do |s|
7
+ s.name = 'logstash-output-newrelic'
8
+ s.version = LogStash::Outputs::NewRelicVersion::VERSION
9
+ s.licenses = ['Apache-2.0']
10
+ s.summary = "Sends Logstash events to New Relic"
11
+ s.homepage = 'https://github.com/newrelic/logstash-output-plugin'
12
+ s.authors = ['New Relic Logging Team']
13
+ s.email = 'logging-team@newrelic.com'
14
+ s.require_paths = ['lib']
15
+
16
+ # This is a Logstash plugin and requires JRuby
17
+ s.platform = 'java'
18
+
19
+ # Files
20
+ s.files = Dir['lib/**/*','spec/**/*','vendor/**/*','*.gemspec','*.md','CONTRIBUTORS','Gemfile','LICENSE','NOTICE.TXT']
21
+ # Tests
22
+ s.test_files = s.files.grep(%r{^(test|spec|features)/})
23
+
24
+ # Special flag to let us know this is actually a logstash plugin
25
+ s.metadata = { "logstash_plugin" => "true", "logstash_group" => "output" }
26
+
27
+ # Gem dependencies
28
+ s.add_runtime_dependency "logstash-core-plugin-api", "~> 2.0"
29
+ s.add_runtime_dependency "logstash-codec-plain"
30
+ s.add_runtime_dependency "manticore"
31
+ s.add_development_dependency "logstash-devutils"
32
+ s.add_development_dependency "webmock"
33
+ s.add_development_dependency "rspec"
34
+ s.add_development_dependency "rspec-wait"
35
+ s.add_development_dependency "rspec_junit_formatter"
36
+ end