fluent-plugin-dynatrace-radiant 0.1.1 → 0.1.2
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
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 5370b16e8925bce1279283e9d76a2fd16b4332f5b5284885e15c30158c60c8af
|
|
4
|
+
data.tar.gz: 192fbb0a0fe7a8709a0b9c6dd81889493a49d79f4fe6df01a301fc04e6eb1e03
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 5d11024920be58d54250295d82a09243f06193ac765f31383812c7ab79f653916c78cd7a3a31e2f06f39f65b77e1922e9238b15df2cb186981df8212b54b9390
|
|
7
|
+
data.tar.gz: dafe657ac421d62d22c61c17503b19faf4120c23032aaea5242e546b97b71c70b8299edad0c3302ab68a6f7cd0a53322510e675ec357f1afeb300420fa3ad102
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
require 'fluent/plugin/output'
|
|
9
9
|
require 'net/http'
|
|
10
|
+
require 'json'
|
|
10
11
|
require_relative 'dynatrace_radiant_constants'
|
|
11
12
|
|
|
12
13
|
module Fluent
|
|
@@ -21,6 +22,13 @@ module Fluent
|
|
|
21
22
|
|
|
22
23
|
helpers :compat_parameters
|
|
23
24
|
|
|
25
|
+
CONTENT_KEYS = %w[content message payload body log _raw].freeze
|
|
26
|
+
LEVEL_KEYS = %w[loglevel status severity level syslog.severity log_level log.level @l severitytext lvl levelname].freeze
|
|
27
|
+
TEXT_LEVEL_PATTERN = /\A\[?\d{4}-\d{1,2}-\d{1,2}[T ]
|
|
28
|
+
\d{1,2}:\d{2}(?::\d{2})?(?:[.,]\d+)?(?:Z|[+-]\d{2}:?\d{2})?\]?\s+
|
|
29
|
+
(?:\[[^\]\r\n]{0,128}\]\s+)?
|
|
30
|
+
(TRACE|DEBUG|INFO|NOTICE|WARN|WARNING|ERROR|SEVERE|FATAL|CRITICAL)\b/x.freeze
|
|
31
|
+
|
|
24
32
|
desc 'The full URL of the Dynatrace log ingestion endpoint'
|
|
25
33
|
config_param :active_gate_url, :string
|
|
26
34
|
|
|
@@ -46,15 +54,32 @@ module Fluent
|
|
|
46
54
|
config_param :read_timeout, :integer, default: 30
|
|
47
55
|
|
|
48
56
|
desc 'Inject timestamp into each log message'
|
|
49
|
-
config_param :inject_timestamp, :bool, default:
|
|
57
|
+
config_param :inject_timestamp, :bool, default: true
|
|
50
58
|
|
|
51
59
|
desc 'Maximum number of records per batch request'
|
|
52
60
|
config_param :batch_size, :integer, default: 5000
|
|
53
61
|
|
|
62
|
+
desc 'Maximum serialized bytes per request, except for an oversized single record'
|
|
63
|
+
config_param :max_request_bytes, :size, default: 4 * 1024 * 1024
|
|
64
|
+
|
|
65
|
+
desc 'Generate missing log content as logfmt, JSON, or leave it absent'
|
|
66
|
+
config_param :content_fallback, :enum, list: [:logfmt, :json, :none], default: :logfmt
|
|
67
|
+
|
|
68
|
+
desc 'Exact key names to omit from generated content; @timestamp is always omitted'
|
|
69
|
+
config_param :content_fallback_exclude_keys, :array, value_type: :string, default: []
|
|
70
|
+
|
|
71
|
+
desc 'Derive a missing log level from a timestamped text message'
|
|
72
|
+
config_param :level_from_message, :bool, default: true
|
|
73
|
+
|
|
74
|
+
desc 'LDAP result code field used for a missing log level; empty disables it'
|
|
75
|
+
config_param :ldap_result_code_key, :string, default: 'RESULTCODE'
|
|
76
|
+
|
|
54
77
|
config_section :buffer do
|
|
55
78
|
config_set_default :flush_at_shutdown, true
|
|
56
79
|
config_set_default :chunk_limit_size, 5 * 1024 * 1024 # 5MB
|
|
57
80
|
config_set_default :flush_interval, 5
|
|
81
|
+
config_set_default :total_limit_size, 64 * 1024 * 1024 # 64MB per output
|
|
82
|
+
config_set_default :overflow_action, :drop_oldest_chunk
|
|
58
83
|
end
|
|
59
84
|
|
|
60
85
|
def configure(conf)
|
|
@@ -62,31 +87,54 @@ module Fluent
|
|
|
62
87
|
super
|
|
63
88
|
|
|
64
89
|
raise Fluent::ConfigError, 'api_token is empty' if @api_token.empty?
|
|
90
|
+
raise Fluent::ConfigError, 'max_request_bytes must be positive' unless @max_request_bytes.positive?
|
|
65
91
|
@uri = parse_and_validate_uri(@active_gate_url)
|
|
92
|
+
@agents = {}
|
|
93
|
+
@agents_mutex = Mutex.new
|
|
66
94
|
end
|
|
67
95
|
|
|
68
|
-
def
|
|
69
|
-
|
|
70
|
-
@agent = create_http_agent
|
|
71
|
-
end
|
|
72
|
-
|
|
73
|
-
def shutdown
|
|
74
|
-
@agent&.finish if @agent&.started?
|
|
96
|
+
def after_shutdown
|
|
97
|
+
# Output stops and joins flush threads here, after the final flush.
|
|
75
98
|
super
|
|
99
|
+
@agents_mutex.synchronize do
|
|
100
|
+
@agents.each_value do |agent|
|
|
101
|
+
begin
|
|
102
|
+
agent.finish if agent.started?
|
|
103
|
+
rescue IOError
|
|
104
|
+
# The peer may already have closed this connection.
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
@agents.clear
|
|
108
|
+
end
|
|
76
109
|
end
|
|
77
110
|
|
|
78
111
|
def write(chunk)
|
|
79
112
|
records = []
|
|
113
|
+
parts = []
|
|
114
|
+
bytes = 3 # Opening bracket, closing bracket and newline.
|
|
80
115
|
chunk.each do |time, record|
|
|
116
|
+
prepare_record(record)
|
|
81
117
|
record['@timestamp'] = (time.to_f * 1000).to_i if @inject_timestamp
|
|
82
|
-
|
|
118
|
+
record = utf8_value(record)
|
|
119
|
+
part = JSON.generate(record)
|
|
83
120
|
|
|
84
|
-
if records.length >= @batch_size
|
|
85
|
-
send_batch(records)
|
|
121
|
+
if !parts.empty? && (records.length >= @batch_size || bytes + 1 + part.bytesize > @max_request_bytes)
|
|
122
|
+
send_batch(records) { "[#{parts.join(',')}]\n" }
|
|
86
123
|
records = []
|
|
124
|
+
parts = []
|
|
125
|
+
bytes = 3
|
|
87
126
|
end
|
|
127
|
+
|
|
128
|
+
if part.bytesize + 3 > @max_request_bytes
|
|
129
|
+
info = { request_bytes: part.bytesize + 3 }
|
|
130
|
+
info[:log_type] = redact_token(record['log_type'].to_s) if record.key?('log_type')
|
|
131
|
+
log.warn 'Dynatrace record exceeds max_request_bytes; sending alone', **info
|
|
132
|
+
end
|
|
133
|
+
bytes += part.bytesize + (parts.empty? ? 0 : 1)
|
|
134
|
+
records << record
|
|
135
|
+
parts << part
|
|
88
136
|
end
|
|
89
|
-
send_batch(records) unless records.empty?
|
|
137
|
+
send_batch(records) { "[#{parts.join(',')}]\n" } unless records.empty?
|
|
90
138
|
end
|
|
91
139
|
|
|
92
140
|
def prefer_buffered_processing
|
|
@@ -94,7 +142,7 @@ module Fluent
|
|
|
94
142
|
end
|
|
95
143
|
|
|
96
144
|
def multi_workers_ready?
|
|
97
|
-
true #
|
|
145
|
+
true # Workers are separate processes; each flush thread has its own HTTP agent.
|
|
98
146
|
end
|
|
99
147
|
|
|
100
148
|
def formatted_to_msgpack_binary?
|
|
@@ -103,6 +151,91 @@ module Fluent
|
|
|
103
151
|
|
|
104
152
|
private
|
|
105
153
|
|
|
154
|
+
def prepare_record(record)
|
|
155
|
+
present = {}
|
|
156
|
+
record.each do |key, value|
|
|
157
|
+
normalized_key = utf8_string(key.to_s).downcase
|
|
158
|
+
present[normalized_key] = value unless value.nil? || present.key?(normalized_key)
|
|
159
|
+
end
|
|
160
|
+
content_key = CONTENT_KEYS.find { |key| present.key?(key) }
|
|
161
|
+
level_key = LEVEL_KEYS.find { |key| present.key?(key) }
|
|
162
|
+
|
|
163
|
+
# Generate from the original fields, before adding a derived log level.
|
|
164
|
+
content = fallback_content(record) if content_key.nil? && @content_fallback != :none
|
|
165
|
+
if level_key.nil?
|
|
166
|
+
level = level_from_text(present[content_key]) if @level_from_message && content_key
|
|
167
|
+
if level.nil? && !@ldap_result_code_key.empty? && record.key?(@ldap_result_code_key)
|
|
168
|
+
level = level_from_ldap_result_code(record[@ldap_result_code_key])
|
|
169
|
+
end
|
|
170
|
+
record['loglevel'] = level if level
|
|
171
|
+
end
|
|
172
|
+
record['content'] = content if content && !content.empty?
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def level_from_text(value)
|
|
176
|
+
text = utf8_string(value.to_s.byteslice(0, 256))
|
|
177
|
+
match = TEXT_LEVEL_PATTERN.match(text)
|
|
178
|
+
return unless match
|
|
179
|
+
|
|
180
|
+
match[1] == 'WARNING' ? 'WARN' : match[1]
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def level_from_ldap_result_code(value)
|
|
184
|
+
if value.is_a?(String)
|
|
185
|
+
value = utf8_string(value).strip
|
|
186
|
+
return unless /\A[0-9]+\z/.match?(value)
|
|
187
|
+
|
|
188
|
+
value = value.to_i
|
|
189
|
+
end
|
|
190
|
+
return unless value.is_a?(Integer)
|
|
191
|
+
|
|
192
|
+
case value
|
|
193
|
+
when 0, 5, 6, 10, 14 then 'INFO'
|
|
194
|
+
when 1, 51, 52, 53, 54, 80 then 'ERROR'
|
|
195
|
+
else 'WARN'
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def fallback_content(record)
|
|
200
|
+
fields = record.reject do |key, value|
|
|
201
|
+
key == '@timestamp' || @content_fallback_exclude_keys.include?(key) || value.nil? || value.to_s.empty?
|
|
202
|
+
end
|
|
203
|
+
return if fields.empty?
|
|
204
|
+
|
|
205
|
+
fields = utf8_value(fields)
|
|
206
|
+
return JSON.generate(fields) if @content_fallback == :json
|
|
207
|
+
|
|
208
|
+
fields.map do |key, value|
|
|
209
|
+
text = value.is_a?(Hash) || value.is_a?(Array) ? JSON.generate(value) : utf8_string(value.to_s)
|
|
210
|
+
if /[[:space:]"=\\]|[[:cntrl:]]/.match?(text)
|
|
211
|
+
text = '"' + text.gsub(/[\\"\n\r\t]/) do |char|
|
|
212
|
+
{ '\\' => '\\\\', '"' => '\\"', "\n" => '\\n', "\r" => '\\r', "\t" => '\\t' }[char]
|
|
213
|
+
end + '"'
|
|
214
|
+
end
|
|
215
|
+
"#{utf8_string(key.to_s)}=#{text}"
|
|
216
|
+
end.join(' ')
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def utf8_string(value)
|
|
220
|
+
if value.encoding == Encoding::BINARY || value.encoding == Encoding::UTF_8
|
|
221
|
+
value.dup.force_encoding(Encoding::UTF_8).scrub("\uFFFD")
|
|
222
|
+
else
|
|
223
|
+
value.encode(Encoding::UTF_8, invalid: :replace, undef: :replace, replace: "\uFFFD")
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def utf8_value(value)
|
|
228
|
+
case value
|
|
229
|
+
when String then utf8_string(value)
|
|
230
|
+
when Hash
|
|
231
|
+
value.each_with_object({}) do |(key, item), result|
|
|
232
|
+
result[utf8_value(key)] = utf8_value(item)
|
|
233
|
+
end
|
|
234
|
+
when Array then value.map { |item| utf8_value(item) }
|
|
235
|
+
else value
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
|
|
106
239
|
def create_http_agent
|
|
107
240
|
agent = Net::HTTP.new(@uri.host, @uri.port)
|
|
108
241
|
agent.open_timeout = @open_timeout
|
|
@@ -120,7 +253,7 @@ module Fluent
|
|
|
120
253
|
|
|
121
254
|
if @client_cert && @client_key
|
|
122
255
|
agent.cert = OpenSSL::X509::Certificate.new(File.read(@client_cert))
|
|
123
|
-
agent.key = OpenSSL::PKey
|
|
256
|
+
agent.key = OpenSSL::PKey.read(File.read(@client_key))
|
|
124
257
|
end
|
|
125
258
|
end
|
|
126
259
|
|
|
@@ -128,26 +261,54 @@ module Fluent
|
|
|
128
261
|
end
|
|
129
262
|
|
|
130
263
|
def send_batch(records)
|
|
131
|
-
ensure_connection
|
|
264
|
+
agent = ensure_connection
|
|
132
265
|
|
|
133
|
-
body
|
|
266
|
+
# write supplies the body from records serialized once for byte accounting.
|
|
267
|
+
body = yield
|
|
134
268
|
req = Net::HTTP::Post.new(@uri)
|
|
135
269
|
req['Content-Type'] = 'application/json; charset=utf-8'
|
|
136
270
|
req['Authorization'] = "Api-Token #{@api_token}"
|
|
137
271
|
req['User-Agent'] = "fluent-plugin-dynatrace-radiant/#{DynatraceRadiantConstants::VERSION}"
|
|
138
272
|
|
|
139
|
-
response =
|
|
273
|
+
response = agent.request(req, body)
|
|
140
274
|
|
|
141
|
-
|
|
142
|
-
|
|
275
|
+
status = response.code.to_i
|
|
276
|
+
if status == 200
|
|
277
|
+
log.warn 'Dynatrace API partial success', status: status, body: response_excerpt(response)
|
|
278
|
+
elsif status == 400 || status == 413
|
|
279
|
+
log.error 'Dynatrace API rejected batch; dropping', status: status,
|
|
280
|
+
record_count: records.length, body: response_excerpt(response)
|
|
281
|
+
elsif !(200..299).cover?(status)
|
|
282
|
+
raise "Dynatrace API error: #{status}"
|
|
143
283
|
end
|
|
284
|
+
rescue StandardError => error
|
|
285
|
+
# Transport errors normally contain no token, but never propagate an echo.
|
|
286
|
+
message = redact_token(error.message)
|
|
287
|
+
raise if message == error.message
|
|
288
|
+
|
|
289
|
+
sanitized = error.exception(message)
|
|
290
|
+
# Net::ReadTimeout/WriteTimeout append their IO's inspect to message.
|
|
291
|
+
sanitized.define_singleton_method(:message) { message }
|
|
292
|
+
sanitized.define_singleton_method(:to_s) { message }
|
|
293
|
+
raise sanitized, cause: nil
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
def redact_token(text)
|
|
297
|
+
utf8_string(text).gsub(@api_token, '[REDACTED]')
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
def response_excerpt(response)
|
|
301
|
+
redact_token(response.body.to_s)[0, 512]
|
|
144
302
|
end
|
|
145
303
|
|
|
146
304
|
def ensure_connection
|
|
147
|
-
@
|
|
305
|
+
agent = @agents_mutex.synchronize { @agents[Thread.current] ||= create_http_agent }
|
|
306
|
+
agent.start unless agent.started?
|
|
307
|
+
agent
|
|
148
308
|
rescue IOError
|
|
149
|
-
|
|
150
|
-
|
|
309
|
+
agent = @agents_mutex.synchronize { @agents[Thread.current] = create_http_agent }
|
|
310
|
+
agent.start
|
|
311
|
+
agent
|
|
151
312
|
end
|
|
152
313
|
|
|
153
314
|
def parse_and_validate_uri(uri_string)
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: fluent-plugin-dynatrace-radiant
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.1.
|
|
4
|
+
version: 0.1.2
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Radiant Logic, Inc.
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-
|
|
11
|
+
date: 2026-11-03 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: fluentd
|