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: c93e634c100e7fd8c21c7150e29a2a629aa4ced8ffa51897ffae2e8b379227ae
4
- data.tar.gz: '019dd4c906607e9837b61fbaebc7a4cc8ae81b3d62a21607b4bcc697acd56c3b'
3
+ metadata.gz: 5370b16e8925bce1279283e9d76a2fd16b4332f5b5284885e15c30158c60c8af
4
+ data.tar.gz: 192fbb0a0fe7a8709a0b9c6dd81889493a49d79f4fe6df01a301fc04e6eb1e03
5
5
  SHA512:
6
- metadata.gz: 7fb1fa1b0a7b5159611d688183d19377ad3602975b5eb795fd3798469c0ad73cbd287ee916c46d61d3abada3740d9aaa988d59c5de9491bdd234889380a2d876
7
- data.tar.gz: f72832ff5efff6a9c61188ca9416d9ba04e0dabf5cea917ab73cc2fdf78c34ad39051bb93ff03f3f1afced4846cc1a53ec7217b3731b7739b60b485efa14b636
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.0'
6
+ VERSION = '0.1.2'
7
7
  end
8
8
  end
9
9
  end
@@ -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: false
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 start
64
- super
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
- records.push(record)
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 # 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.
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::RSA.new(File.read(@client_key))
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 = "#{records.to_json}\n"
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 = @agent.request(req, body)
273
+ response = agent.request(req, body)
135
274
 
136
- unless response.is_a?(Net::HTTPSuccess)
137
- 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}"
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
- @agent.start unless @agent.started?
305
+ agent = @agents_mutex.synchronize { @agents[Thread.current] ||= create_http_agent }
306
+ agent.start unless agent.started?
307
+ agent
143
308
  rescue IOError
144
- @agent = create_http_agent
145
- @agent.start
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.0
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-04-07 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
@@ -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: