fluent-plugin-dynatrace-radiant 0.1.0 → 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
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Copyright 2026 Radiant Logic, Inc.
|
|
4
|
+
#
|
|
5
|
+
# Licensed under the Apache License, Version 2.0
|
|
6
|
+
|
|
7
|
+
# Loader for the vendor-neutral plugin name. Fluentd resolves `@type dynatrace`
|
|
8
|
+
# by requiring fluent/plugin/out_dynatrace; the implementation lives in
|
|
9
|
+
# out_dynatrace_radiant, which registers both names.
|
|
10
|
+
require 'fluent/plugin/out_dynatrace_radiant'
|
|
@@ -7,15 +7,28 @@
|
|
|
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
|
|
13
14
|
module Plugin
|
|
14
15
|
class DynatraceRadiantOutput < Output
|
|
15
16
|
Fluent::Plugin.register_output('dynatrace_radiant', self)
|
|
17
|
+
# Also answer to the vendor-neutral name, like the other *-radiant forks do,
|
|
18
|
+
# so `@type dynatrace` works without a loader shim in the consuming image.
|
|
19
|
+
# Fluentd resolves `@type dynatrace` by requiring fluent/plugin/out_dynatrace,
|
|
20
|
+
# which this gem ships as a thin loader for this file.
|
|
21
|
+
Fluent::Plugin.register_output('dynatrace', self)
|
|
16
22
|
|
|
17
23
|
helpers :compat_parameters
|
|
18
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
|
+
|
|
19
32
|
desc 'The full URL of the Dynatrace log ingestion endpoint'
|
|
20
33
|
config_param :active_gate_url, :string
|
|
21
34
|
|
|
@@ -41,15 +54,32 @@ module Fluent
|
|
|
41
54
|
config_param :read_timeout, :integer, default: 30
|
|
42
55
|
|
|
43
56
|
desc 'Inject timestamp into each log message'
|
|
44
|
-
config_param :inject_timestamp, :bool, default:
|
|
57
|
+
config_param :inject_timestamp, :bool, default: true
|
|
45
58
|
|
|
46
59
|
desc 'Maximum number of records per batch request'
|
|
47
60
|
config_param :batch_size, :integer, default: 5000
|
|
48
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
|
+
|
|
49
77
|
config_section :buffer do
|
|
50
78
|
config_set_default :flush_at_shutdown, true
|
|
51
79
|
config_set_default :chunk_limit_size, 5 * 1024 * 1024 # 5MB
|
|
52
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
|
|
53
83
|
end
|
|
54
84
|
|
|
55
85
|
def configure(conf)
|
|
@@ -57,31 +87,54 @@ module Fluent
|
|
|
57
87
|
super
|
|
58
88
|
|
|
59
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?
|
|
60
91
|
@uri = parse_and_validate_uri(@active_gate_url)
|
|
92
|
+
@agents = {}
|
|
93
|
+
@agents_mutex = Mutex.new
|
|
61
94
|
end
|
|
62
95
|
|
|
63
|
-
def
|
|
64
|
-
|
|
65
|
-
@agent = create_http_agent
|
|
66
|
-
end
|
|
67
|
-
|
|
68
|
-
def shutdown
|
|
69
|
-
@agent&.finish if @agent&.started?
|
|
96
|
+
def after_shutdown
|
|
97
|
+
# Output stops and joins flush threads here, after the final flush.
|
|
70
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
|
|
71
109
|
end
|
|
72
110
|
|
|
73
111
|
def write(chunk)
|
|
74
112
|
records = []
|
|
113
|
+
parts = []
|
|
114
|
+
bytes = 3 # Opening bracket, closing bracket and newline.
|
|
75
115
|
chunk.each do |time, record|
|
|
116
|
+
prepare_record(record)
|
|
76
117
|
record['@timestamp'] = (time.to_f * 1000).to_i if @inject_timestamp
|
|
77
|
-
|
|
118
|
+
record = utf8_value(record)
|
|
119
|
+
part = JSON.generate(record)
|
|
78
120
|
|
|
79
|
-
if records.length >= @batch_size
|
|
80
|
-
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" }
|
|
81
123
|
records = []
|
|
124
|
+
parts = []
|
|
125
|
+
bytes = 3
|
|
82
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
|
|
83
136
|
end
|
|
84
|
-
send_batch(records) unless records.empty?
|
|
137
|
+
send_batch(records) { "[#{parts.join(',')}]\n" } unless records.empty?
|
|
85
138
|
end
|
|
86
139
|
|
|
87
140
|
def prefer_buffered_processing
|
|
@@ -89,7 +142,7 @@ module Fluent
|
|
|
89
142
|
end
|
|
90
143
|
|
|
91
144
|
def multi_workers_ready?
|
|
92
|
-
true #
|
|
145
|
+
true # Workers are separate processes; each flush thread has its own HTTP agent.
|
|
93
146
|
end
|
|
94
147
|
|
|
95
148
|
def formatted_to_msgpack_binary?
|
|
@@ -98,6 +151,91 @@ module Fluent
|
|
|
98
151
|
|
|
99
152
|
private
|
|
100
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
|
+
|
|
101
239
|
def create_http_agent
|
|
102
240
|
agent = Net::HTTP.new(@uri.host, @uri.port)
|
|
103
241
|
agent.open_timeout = @open_timeout
|
|
@@ -115,7 +253,7 @@ module Fluent
|
|
|
115
253
|
|
|
116
254
|
if @client_cert && @client_key
|
|
117
255
|
agent.cert = OpenSSL::X509::Certificate.new(File.read(@client_cert))
|
|
118
|
-
agent.key = OpenSSL::PKey
|
|
256
|
+
agent.key = OpenSSL::PKey.read(File.read(@client_key))
|
|
119
257
|
end
|
|
120
258
|
end
|
|
121
259
|
|
|
@@ -123,26 +261,54 @@ module Fluent
|
|
|
123
261
|
end
|
|
124
262
|
|
|
125
263
|
def send_batch(records)
|
|
126
|
-
ensure_connection
|
|
264
|
+
agent = ensure_connection
|
|
127
265
|
|
|
128
|
-
body
|
|
266
|
+
# write supplies the body from records serialized once for byte accounting.
|
|
267
|
+
body = yield
|
|
129
268
|
req = Net::HTTP::Post.new(@uri)
|
|
130
269
|
req['Content-Type'] = 'application/json; charset=utf-8'
|
|
131
270
|
req['Authorization'] = "Api-Token #{@api_token}"
|
|
132
271
|
req['User-Agent'] = "fluent-plugin-dynatrace-radiant/#{DynatraceRadiantConstants::VERSION}"
|
|
133
272
|
|
|
134
|
-
response =
|
|
273
|
+
response = agent.request(req, body)
|
|
135
274
|
|
|
136
|
-
|
|
137
|
-
|
|
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}"
|
|
138
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]
|
|
139
302
|
end
|
|
140
303
|
|
|
141
304
|
def ensure_connection
|
|
142
|
-
@
|
|
305
|
+
agent = @agents_mutex.synchronize { @agents[Thread.current] ||= create_http_agent }
|
|
306
|
+
agent.start unless agent.started?
|
|
307
|
+
agent
|
|
143
308
|
rescue IOError
|
|
144
|
-
|
|
145
|
-
|
|
309
|
+
agent = @agents_mutex.synchronize { @agents[Thread.current] = create_http_agent }
|
|
310
|
+
agent.start
|
|
311
|
+
agent
|
|
146
312
|
end
|
|
147
313
|
|
|
148
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
|
|
@@ -89,6 +89,7 @@ extra_rdoc_files: []
|
|
|
89
89
|
files:
|
|
90
90
|
- LICENSE
|
|
91
91
|
- lib/fluent/plugin/dynatrace_radiant_constants.rb
|
|
92
|
+
- lib/fluent/plugin/out_dynatrace.rb
|
|
92
93
|
- lib/fluent/plugin/out_dynatrace_radiant.rb
|
|
93
94
|
homepage: https://github.com/gnanirahulnutakki/fluent-plugin-dynatrace-radiant
|
|
94
95
|
licenses:
|
|
@@ -96,6 +97,7 @@ licenses:
|
|
|
96
97
|
metadata:
|
|
97
98
|
bug_tracker_uri: https://github.com/gnanirahulnutakki/fluent-plugin-dynatrace-radiant/issues
|
|
98
99
|
source_code_uri: https://github.com/gnanirahulnutakki/fluent-plugin-dynatrace-radiant
|
|
100
|
+
rubygems_mfa_required: 'true'
|
|
99
101
|
post_install_message:
|
|
100
102
|
rdoc_options: []
|
|
101
103
|
require_paths:
|