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: 9b83e5026a95afbaabec8cd191a2098da1d0b1330183ab2a9ba7fea352466c26
4
- data.tar.gz: 3481de5e791b40e8eb1084fe88c3f916e26996f99afda8fb1a25dbb134b4cf56
3
+ metadata.gz: 5370b16e8925bce1279283e9d76a2fd16b4332f5b5284885e15c30158c60c8af
4
+ data.tar.gz: 192fbb0a0fe7a8709a0b9c6dd81889493a49d79f4fe6df01a301fc04e6eb1e03
5
5
  SHA512:
6
- metadata.gz: aef4a985c2839024c4e12dca5db15ef732759ab21a6accd64d32402d5d7442998c8c612b9bfee275eb16e6f88c6f62b32f2b392d71b74592a4513ca3f09b0bb7
7
- data.tar.gz: caced0055d07f68dbc44ef4291320f81165e576d30e4ab4cee3d7ffc6887196d3a9c7e02dbecbf7d11fbd9b737ee53724791e10e440870836a8b09043a862d87
6
+ metadata.gz: 5d11024920be58d54250295d82a09243f06193ac765f31383812c7ab79f653916c78cd7a3a31e2f06f39f65b77e1922e9238b15df2cb186981df8212b54b9390
7
+ data.tar.gz: dafe657ac421d62d22c61c17503b19faf4120c23032aaea5242e546b97b71c70b8299edad0c3302ab68a6f7cd0a53322510e675ec357f1afeb300420fa3ad102
@@ -3,7 +3,7 @@
3
3
  module Fluent
4
4
  module Plugin
5
5
  module DynatraceRadiantConstants
6
- VERSION = '0.1.1'
6
+ VERSION = '0.1.2'
7
7
  end
8
8
  end
9
9
  end
@@ -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: false
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 start
69
- super
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
- records.push(record)
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 # Thread-safe: each worker gets its own HTTP agent via start()
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::RSA.new(File.read(@client_key))
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 = "#{records.to_json}\n"
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 = @agent.request(req, body)
273
+ response = agent.request(req, body)
140
274
 
141
- unless response.is_a?(Net::HTTPSuccess)
142
- raise "Dynatrace API error: #{response.code} #{response.message} #{response.body}"
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
- @agent.start unless @agent.started?
305
+ agent = @agents_mutex.synchronize { @agents[Thread.current] ||= create_http_agent }
306
+ agent.start unless agent.started?
307
+ agent
148
308
  rescue IOError
149
- @agent = create_http_agent
150
- @agent.start
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.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-09-02 00:00:00.000000000 Z
11
+ date: 2026-11-03 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: fluentd