logstash-output-newrelic 2.0.0.pre.beta-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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +2 -0
- data/CONTRIBUTORS +10 -0
- data/DEVELOPER.md +37 -0
- data/Gemfile +18 -0
- data/LICENSE +201 -0
- data/README.md +96 -0
- data/lib/logstash/outputs/config/bigdecimal_patch.rb +24 -0
- data/lib/logstash/outputs/exception/error.rb +14 -0
- data/lib/logstash/outputs/newrelic.rb +276 -0
- data/lib/logstash/outputs/newrelic_version/version.rb +7 -0
- data/logstash-output-newrelic.gemspec +36 -0
- data/spec/outputs/input_17997_messages_resulting_in_2680KB_compressed_payload.json +17997 -0
- data/spec/outputs/input_5000_messages_resulting_in_740KB_compressed_payload.json +5000 -0
- data/spec/outputs/newrelic_spec.rb +603 -0
- data/spec/outputs/single_input_message_exceeeding_1MB_once_compressed.json +1 -0
- metadata +175 -0
|
@@ -0,0 +1,276 @@
|
|
|
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
|
+
# Only used for E2E testing
|
|
28
|
+
config :custom_ca_cert, :validate => :string, :required => false
|
|
29
|
+
|
|
30
|
+
public
|
|
31
|
+
|
|
32
|
+
def register
|
|
33
|
+
@end_point = URI.parse(@base_uri)
|
|
34
|
+
if @api_key.nil? && @license_key.nil?
|
|
35
|
+
raise LogStash::ConfigurationError, "Must provide a license key or api key", caller
|
|
36
|
+
end
|
|
37
|
+
@logger.info("Registering logstash-output-newrelic", :version => LogStash::Outputs::NewRelicVersion::VERSION, :target => @base_uri)
|
|
38
|
+
auth = {
|
|
39
|
+
@api_key.nil? ? 'X-License-Key' : 'X-Insert-Key' =>
|
|
40
|
+
@api_key.nil? ? @license_key.value : @api_key.value
|
|
41
|
+
}
|
|
42
|
+
@header = {
|
|
43
|
+
'X-Event-Source' => 'logs',
|
|
44
|
+
'Content-Encoding' => 'gzip',
|
|
45
|
+
'Content-Type' => 'application/json'
|
|
46
|
+
}.merge(auth).freeze
|
|
47
|
+
|
|
48
|
+
client_options = {
|
|
49
|
+
:pool_max => @concurrent_requests,
|
|
50
|
+
:pool_max_per_route => @concurrent_requests
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
# Only configure SSL if using HTTPS
|
|
54
|
+
if @end_point.scheme == 'https'
|
|
55
|
+
client_options[:ssl] = {
|
|
56
|
+
:verify => :default
|
|
57
|
+
}
|
|
58
|
+
# Set reasonable timeouts for the HTTP client
|
|
59
|
+
client_options[:connect_timeout] = 30
|
|
60
|
+
client_options[:socket_timeout] = 30
|
|
61
|
+
|
|
62
|
+
if !@custom_ca_cert.nil?
|
|
63
|
+
# Load the custom CA certificate
|
|
64
|
+
# For test environments with self-signed certs, disable verification
|
|
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
|
+
end
|
|
79
|
+
|
|
80
|
+
# Shutdown hook called by Logstash 5.x and 6.x versions during pipeline shutdown
|
|
81
|
+
def stop
|
|
82
|
+
shutdown
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Shutdown hook called by Logstash 7.x+ versions during pipeline shutdown
|
|
86
|
+
def close
|
|
87
|
+
shutdown
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Additional shutdown hook for cleanup, called by some Logstash versions
|
|
91
|
+
def teardown
|
|
92
|
+
shutdown
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Used by tests so that the test run can complete (background threads prevent JVM exit)
|
|
96
|
+
def shutdown
|
|
97
|
+
if @executor
|
|
98
|
+
@logger.info("Draining outstanding New Relic requests")
|
|
99
|
+
@executor.shutdown
|
|
100
|
+
# We want this long enough to not have threading issues
|
|
101
|
+
terminationWaitInSeconds = 10
|
|
102
|
+
terminatedInTime = @executor.awaitTermination(terminationWaitInSeconds, java.util.concurrent.TimeUnit::SECONDS)
|
|
103
|
+
if !terminatedInTime
|
|
104
|
+
raise "Did not shut down within #{terminationWaitInSeconds} seconds"
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
if defined?(@client) && @client
|
|
109
|
+
@logger.info("Closing New Relic HTTP client")
|
|
110
|
+
@client.close
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def time_to_logstash_timestamp(time)
|
|
115
|
+
begin
|
|
116
|
+
LogStash::Timestamp.coerce(time)
|
|
117
|
+
rescue
|
|
118
|
+
nil
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def to_nr_logs(logstash_events)
|
|
123
|
+
logstash_events.map do |logstash_event|
|
|
124
|
+
event_hash = logstash_event.to_hash
|
|
125
|
+
|
|
126
|
+
nr_log_message_hash = {
|
|
127
|
+
# non-intrinsic attributes get put into 'attributes'
|
|
128
|
+
:attributes => event_hash
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
# intrinsic attributes go at the top level
|
|
132
|
+
if event_hash['message']
|
|
133
|
+
nr_log_message_hash['message'] = event_hash['message']
|
|
134
|
+
nr_log_message_hash[:attributes].delete('message')
|
|
135
|
+
end
|
|
136
|
+
if event_hash['timestamp']
|
|
137
|
+
nr_log_message_hash['timestamp'] = event_hash['timestamp']
|
|
138
|
+
nr_log_message_hash[:attributes].delete('timestamp')
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
nr_log_message_hash
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def multi_receive(logstash_events)
|
|
146
|
+
if logstash_events.empty?
|
|
147
|
+
return
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
nr_logs = to_nr_logs(logstash_events)
|
|
151
|
+
|
|
152
|
+
@logger.info("Submitting logs to New Relic", :event_count => nr_logs.length)
|
|
153
|
+
|
|
154
|
+
submit_logs_to_be_sent(nr_logs)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def submit_logs_to_be_sent(nr_logs)
|
|
158
|
+
@semaphore.acquire()
|
|
159
|
+
execute = @executor.java_method :submit, [java.lang.Runnable]
|
|
160
|
+
execute.call do
|
|
161
|
+
begin
|
|
162
|
+
package_and_send_recursively(nr_logs)
|
|
163
|
+
ensure
|
|
164
|
+
@semaphore.release()
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def package_and_send_recursively(nr_logs)
|
|
170
|
+
payload = {
|
|
171
|
+
:common => {
|
|
172
|
+
:attributes => {
|
|
173
|
+
:plugin => {
|
|
174
|
+
:type => 'logstash',
|
|
175
|
+
:version => LogStash::Outputs::NewRelicVersion::VERSION,
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
},
|
|
179
|
+
:logs => nr_logs
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
payload_json = [payload].to_json
|
|
183
|
+
compressed_payload = gzip_compress(payload_json, Zlib::DEFAULT_COMPRESSION)
|
|
184
|
+
compressed_size = compressed_payload.bytesize
|
|
185
|
+
log_record_count = nr_logs.length
|
|
186
|
+
|
|
187
|
+
if compressed_size >= MAX_PAYLOAD_SIZE_BYTES && log_record_count == 1
|
|
188
|
+
@logger.error("Can't compress record below required maximum packet size and it will be discarded.")
|
|
189
|
+
elsif compressed_size >= MAX_PAYLOAD_SIZE_BYTES && log_record_count > 1
|
|
190
|
+
@logger.debug("Compressed payload size exceeds maximum packet size, splitting payload", :compressed_size => compressed_size)
|
|
191
|
+
split_index = log_record_count / 2
|
|
192
|
+
@logger.debug("Splitting payload", :split_index => split_index, :first_half => split_index, :second_half => log_record_count - split_index)
|
|
193
|
+
package_and_send_recursively(nr_logs[0...split_index])
|
|
194
|
+
package_and_send_recursively(nr_logs[split_index..-1])
|
|
195
|
+
else
|
|
196
|
+
nr_send(compressed_payload)
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def handle_response(response)
|
|
201
|
+
if !(200 <= response.code && response.code < 300)
|
|
202
|
+
raise Error::BadResponseCodeError.new(response.code, @base_uri)
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# Compresses a given payload string using GZIP.
|
|
207
|
+
#
|
|
208
|
+
# @param payload [String] The string payload to be compressed.
|
|
209
|
+
# @param compression_level [Integer] The GZIP compression level to use.
|
|
210
|
+
# @return [String] The GZIP-compressed binary string.
|
|
211
|
+
def gzip_compress(payload, compression_level)
|
|
212
|
+
string_io = StringIO.new
|
|
213
|
+
string_io.set_encoding("BINARY")
|
|
214
|
+
Zlib::GzipWriter.wrap(string_io, compression_level) do |gz|
|
|
215
|
+
gz.write(payload)
|
|
216
|
+
end
|
|
217
|
+
string_io.string
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def nr_send(payload)
|
|
221
|
+
retries = 0
|
|
222
|
+
retry_duration = 1
|
|
223
|
+
|
|
224
|
+
begin
|
|
225
|
+
@logger.debug("Dispatching payload to New Relic", :endpoint => @base_uri, :payload_size => payload.bytesize)
|
|
226
|
+
response = @client.post(@base_uri, :body => payload, :headers => @header)
|
|
227
|
+
@logger.debug("Received response from New Relic", :code => response.code, :message => response.message)
|
|
228
|
+
handle_response(response)
|
|
229
|
+
if (retries > 0)
|
|
230
|
+
@logger.warn("Successfully sent logs at retry #{retries}")
|
|
231
|
+
else
|
|
232
|
+
@logger.debug("Successfully sent logs to New Relic", :response_code => response.code)
|
|
233
|
+
end
|
|
234
|
+
rescue Error::BadResponseCodeError => e
|
|
235
|
+
@logger.error(e.message)
|
|
236
|
+
if (should_retry(retries) && is_retryable_code(e))
|
|
237
|
+
retries += 1
|
|
238
|
+
sleep(retry_duration)
|
|
239
|
+
retry_duration *= 2
|
|
240
|
+
retry
|
|
241
|
+
end
|
|
242
|
+
rescue => e
|
|
243
|
+
# Stuff that should never happen
|
|
244
|
+
# For all other errors print out full issues
|
|
245
|
+
if (should_retry(retries))
|
|
246
|
+
retries += 1
|
|
247
|
+
@logger.warn(
|
|
248
|
+
"An unknown error occurred sending a bulk request to NewRelic. Retrying...",
|
|
249
|
+
:retries => "attempt #{retries} of #{@max_retries}",
|
|
250
|
+
:error_message => e.message,
|
|
251
|
+
:error_class => e.class.name,
|
|
252
|
+
:backtrace => e.backtrace
|
|
253
|
+
)
|
|
254
|
+
sleep(retry_duration)
|
|
255
|
+
retry_duration *= 2
|
|
256
|
+
retry
|
|
257
|
+
else
|
|
258
|
+
@logger.error(
|
|
259
|
+
"An unknown error occurred sending a bulk request to NewRelic. Maximum of attempts reached, dropping logs.",
|
|
260
|
+
:error_message => e.message,
|
|
261
|
+
:error_class => e.class.name,
|
|
262
|
+
:backtrace => e.backtrace
|
|
263
|
+
)
|
|
264
|
+
end
|
|
265
|
+
end
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
def should_retry(retries)
|
|
269
|
+
retries < @max_retries
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
def is_retryable_code(response_error)
|
|
273
|
+
error_code = response_error.response_code
|
|
274
|
+
RETRIABLE_CODES.include?(error_code)
|
|
275
|
+
end
|
|
276
|
+
end # class LogStash::Outputs::NewRelic
|
|
@@ -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
|