fluent-plugin-opentelemetry-radiant 0.1.0 → 0.1.1

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: 5b3f35dd254aa50aee69218bd2a13ecdf80222d9c80f216694a3728821cead78
4
- data.tar.gz: 124931e149bc6def82b37f5ce24c2e68995ec6040819ccfd9bc6a85eaa81d92a
3
+ metadata.gz: '08cf17bafeddfd37c8ea288aeea8bdc9f3c9c6f12383d37b2b78d43a5e16087a'
4
+ data.tar.gz: ec2cdb92e14a3323c84451256523b8df2084cbb5865fa93f47620a4ac56f4990
5
5
  SHA512:
6
- metadata.gz: 1edb669c95e239d288c03819d14a1559dbf023fa8942b2be39435c4f60f7bb819175ac07798786d85566c7e95010dcb0c296ab3d680cbe5cee4ec08a87ad0a0f
7
- data.tar.gz: f7ac40e9c4b7fe92e2956c28ceb2e89e1d2a4482720f276e1d6a6d3452017c46a632e423456bc66d9f6e8f2a50dbfe0cbcb63e7587af0f3fb64b30da8fec59f8
6
+ metadata.gz: 57c372462c52f818e06427d2867e3534c267ccd4a4a43182ce1fe365255ce59143054959d26629f283487faef0a03a66f368dc10909d59aad2a161100b6794ee
7
+ data.tar.gz: ca0f93fc4f847113465ba7c54625491ec7491a1303da6bc1e85f99e7606c5230ccfc650fcb7ee1f0dce0344eee168d3fcf3ea968f37ed3049ae927454219f163
data/CHANGELOG.md CHANGED
@@ -1,9 +1,11 @@
1
- ## [Unreleased]
1
+ ## [0.1.1] - 2026-10-28
2
2
 
3
- - Normalize log strings to valid UTF-8 without mutating records, preventing binary or invalid bytes from dropping whole chunks.
3
+ - Check gRPC TLS files before each export and reconnect when certificates or
4
+ keys change, without a Fluentd reload or restart. Keep the current channel if the new files cannot be loaded.
4
5
 
5
- ## [0.1.0] - 2026-09-02
6
+ ## [0.1.0] - 2026-10-27
6
7
 
8
+ - Normalize log strings to valid UTF-8 without mutating records, preventing binary or invalid bytes from dropping whole chunks.
7
9
  - Add secret-masked authentication headers for HTTP and lowercase gRPC metadata.
8
10
  - Add HTTP CA and mutual TLS settings without changing global Excon defaults;
9
11
  warn once at start when HTTP certificate verification is disabled.
data/README.md CHANGED
@@ -200,6 +200,14 @@ a `Fluent::ConfigError` identifying the path. An encrypted client key can use
200
200
  The HTTP `min_version` / `max_version` settings are not applied to gRPC, whose
201
201
  TLS negotiation is managed by gRPC core.
202
202
 
203
+ **Certificate rotation.** HTTP opens new connections with the TLS files as they
204
+ are on disk. gRPC checks the configured TLS files before each send and reconnects
205
+ when their contents change. If the files cannot be read or the new credentials
206
+ are invalid, gRPC warns and keeps its current channel, then checks again on the
207
+ next send. Headers (including tokens) are read when the configuration loads, so
208
+ a token change needs a config reload. Mount Kubernetes Secrets as a directory,
209
+ not with `subPath`, because kubelet does not update `subPath` mounts.
210
+
203
211
  | gRPC option | Default | Meaning |
204
212
  |---|---|---|
205
213
  | `compress` | `text` | `text` sends uncompressed messages; `gzip` enables gRPC gzip compression. |
@@ -19,6 +19,8 @@ require "opentelemetry/proto/collector/trace/v1/trace_service_services_pb"
19
19
  require "fluent/plugin/opentelemetry/constant"
20
20
  require "fluent/plugin/opentelemetry/output_response"
21
21
  require "google/protobuf"
22
+ require "digest"
23
+ require "set"
22
24
 
23
25
  class Fluent::Plugin::Opentelemetry::GrpcOutputHandler
24
26
  include Fluent::Plugin::Opentelemetry::OutputResponse
@@ -30,6 +32,8 @@ class Fluent::Plugin::Opentelemetry::GrpcOutputHandler
30
32
  [GRPC::Core::StatusCodes.const_get(name), name.to_s]
31
33
  end.freeze
32
34
 
35
+ class TlsFileError < StandardError; end
36
+
33
37
  class ServiceStub
34
38
  class Logs
35
39
  def initialize(host, creds, **kw)
@@ -82,26 +86,38 @@ class Fluent::Plugin::Opentelemetry::GrpcOutputHandler
82
86
  raise Fluent::ConfigError, "certificate verification cannot be turned off for gRPC; set ca_path to the server's CA instead"
83
87
  end
84
88
 
85
- credential = channel_credentials(transport_config)
86
- channel_args = {}
89
+ @transport_config = transport_config
90
+ @tls_mutex = Mutex.new
91
+ @active_exports = Hash.new(0)
92
+ @tls_failures = Set.new
93
+ if transport_config&.protocol == :tls
94
+ @tls_paths = {
95
+ ca_path: transport_config.ca_path, cert_path: transport_config.cert_path,
96
+ private_key_path: transport_config.private_key_path
97
+ }.compact
98
+ end
99
+ pems = read_tls_files if @tls_paths
100
+ credential = channel_credentials(pems)
101
+ @tls_fingerprints = fingerprints(pems) if @tls_paths
102
+ @channel_args = {}
87
103
  if grpc_config.compress == :gzip
88
- channel_args = GRPC::Core::CompressionOptions.new(default_algorithm: :gzip).to_channel_arg_hash
104
+ @channel_args = GRPC::Core::CompressionOptions.new(default_algorithm: :gzip).to_channel_arg_hash
89
105
  end
90
- channel_args["grpc.keepalive_time_ms"] = grpc_config.keepalive_time * 1000
91
- channel_args["grpc.keepalive_timeout_ms"] = grpc_config.keepalive_timeout * 1000
92
- channel_args["grpc.keepalive_permit_without_calls"] = 1
93
- @services = {
94
- Fluent::Plugin::Opentelemetry::RECORD_TYPE_LOGS => ServiceStub::Logs.new(grpc_config.endpoint, credential, channel_args: channel_args),
95
- Fluent::Plugin::Opentelemetry::RECORD_TYPE_METRICS => ServiceStub::Metrics.new(grpc_config.endpoint, credential, channel_args: channel_args),
96
- Fluent::Plugin::Opentelemetry::RECORD_TYPE_TRACES => ServiceStub::Traces.new(grpc_config.endpoint, credential, channel_args: channel_args)
97
- }
106
+ @channel_args["grpc.keepalive_time_ms"] = grpc_config.keepalive_time * 1000
107
+ @channel_args["grpc.keepalive_timeout_ms"] = grpc_config.keepalive_timeout * 1000
108
+ @channel_args["grpc.keepalive_permit_without_calls"] = 1
109
+ @channel_args["grpc.primary_user_agent"] = "grpc-ruby/#{GRPC::VERSION}"
110
+ @channel, @services = build_services(credential)
111
+ rescue TlsFileError => e
112
+ raise Fluent::ConfigError, e.message, cause: nil
98
113
  end
99
114
 
100
115
  def export(record)
101
- service = @services[record["type"]]
102
- raise Fluent::UnrecoverableError, "got unknown record type '#{record['type']}'" unless service
116
+ response = with_service(record["type"]) do |service|
117
+ raise Fluent::UnrecoverableError, "got unknown record type '#{record['type']}'" unless service
103
118
 
104
- response = service.export(record["message"], **call_options)
119
+ service.export(record["message"], **call_options)
120
+ end
105
121
  warn_partial_success(response) if record["type"] == Fluent::Plugin::Opentelemetry::RECORD_TYPE_LOGS
106
122
  rescue Google::Protobuf::ParseError => e
107
123
  raise Fluent::UnrecoverableError, e.message
@@ -111,7 +127,9 @@ class Fluent::Plugin::Opentelemetry::GrpcOutputHandler
111
127
  end
112
128
 
113
129
  def export_logs(request)
114
- response = logs_service.export_request(request, **call_options)
130
+ response = with_service(Fluent::Plugin::Opentelemetry::RECORD_TYPE_LOGS) do |service|
131
+ service.export_request(request, **call_options)
132
+ end
115
133
  warn_partial_success(response)
116
134
  rescue GRPC::BadStatus => e
117
135
  code_name = CODE_NAMES.fetch(e.code, e.code.to_s)
@@ -128,34 +146,121 @@ class Fluent::Plugin::Opentelemetry::GrpcOutputHandler
128
146
  @services.fetch(Fluent::Plugin::Opentelemetry::RECORD_TYPE_LOGS)
129
147
  end
130
148
 
149
+ def service_for(type)
150
+ type == Fluent::Plugin::Opentelemetry::RECORD_TYPE_LOGS ? logs_service : @services[type]
151
+ end
152
+
153
+ def with_service(type)
154
+ return yield service_for(type) unless @tls_paths
155
+
156
+ channel = nil
157
+ service = @tls_mutex.synchronize do
158
+ refresh_tls_files
159
+ channel = @channel
160
+ @active_exports[channel] += 1
161
+ service_for(type)
162
+ end
163
+ yield service
164
+ ensure
165
+ if channel
166
+ @tls_mutex.synchronize do
167
+ @active_exports[channel] -= 1
168
+ if @active_exports[channel].zero?
169
+ @active_exports.delete(channel)
170
+ channel.close unless channel.equal?(@channel)
171
+ end
172
+ end
173
+ end
174
+ end
175
+
176
+ def build_services(credential)
177
+ channel = GRPC::Core::Channel.new(@grpc_config.endpoint, @channel_args, credential)
178
+ options = { channel_override: channel }
179
+ services = {
180
+ Fluent::Plugin::Opentelemetry::RECORD_TYPE_LOGS => ServiceStub::Logs.new(@grpc_config.endpoint, credential, **options),
181
+ Fluent::Plugin::Opentelemetry::RECORD_TYPE_METRICS => ServiceStub::Metrics.new(@grpc_config.endpoint, credential, **options),
182
+ Fluent::Plugin::Opentelemetry::RECORD_TYPE_TRACES => ServiceStub::Traces.new(@grpc_config.endpoint, credential, **options)
183
+ }
184
+ [channel, services]
185
+ rescue StandardError
186
+ channel&.close
187
+ raise
188
+ end
189
+
190
+ def refresh_tls_files
191
+ begin
192
+ pems = read_tls_files
193
+ current = fingerprints(pems)
194
+ if current != @tls_fingerprints
195
+ channel, services = build_services(channel_credentials(pems, rotation: true))
196
+ old_channel = @channel
197
+ @channel, @services, @tls_fingerprints = channel, services, current
198
+ end
199
+ rescue StandardError => e
200
+ message = e.is_a?(TlsFileError) ? e.message : "could not rebuild gRPC TLS channel for #{@tls_paths.values.join(', ')} (#{e.class})"
201
+ failure = [message, current]
202
+ if @tls_failures.add?(failure)
203
+ @logger.warn "#{message}; keeping the current gRPC channel and retrying the TLS files on the next export"
204
+ end
205
+ return
206
+ end
207
+ @tls_failures.clear
208
+ return unless old_channel
209
+
210
+ @logger.info "gRPC TLS files changed; reconnecting with the updated certificate files"
211
+ # Let calls that already selected the old stubs finish before closing them.
212
+ old_channel.close unless @active_exports.key?(old_channel)
213
+ end
214
+
131
215
  def call_options
132
216
  { deadline: Time.now + @grpc_config.timeout, metadata: @metadata }
133
217
  end
134
218
 
135
- def channel_credentials(transport)
136
- return :this_channel_is_insecure unless transport&.protocol == :tls
219
+ def channel_credentials(pems, rotation: false)
220
+ return :this_channel_is_insecure unless @tls_paths
137
221
 
138
- ca_pem = read_pem(transport.ca_path)
139
- key_pem = read_pem(transport.private_key_path)
140
- cert_pem = read_pem(transport.cert_path)
222
+ transport = @transport_config
223
+ ca_pem, key_pem, cert_pem = pems.values_at(:ca_path, :private_key_path, :cert_path)
141
224
  if key_pem.nil? != cert_pem.nil?
142
- raise Fluent::ConfigError, "gRPC TLS requires both cert_path and private_key_path for a client certificate"
225
+ raise TlsFileError, "gRPC TLS requires both cert_path and private_key_path for a client certificate"
143
226
  end
144
- if key_pem && transport.private_key_passphrase
227
+ # Configure-time checks stay as in 0.1.0. A rotation replaces a working
228
+ # channel, so it also requires a parseable key that matches the certificate.
229
+ if key_pem && (transport.private_key_passphrase || rotation)
145
230
  begin
146
- key_pem = OpenSSL::PKey.read(key_pem, transport.private_key_passphrase).to_pem
231
+ key = OpenSSL::PKey.read(key_pem, transport.private_key_passphrase || "")
147
232
  rescue OpenSSL::OpenSSLError, ArgumentError
148
- raise Fluent::ConfigError, "could not decrypt gRPC TLS private_key_path '#{transport.private_key_path}'", cause: nil
233
+ action = transport.private_key_passphrase ? "decrypt" : "read"
234
+ raise TlsFileError, "could not #{action} gRPC TLS private_key_path '#{transport.private_key_path}'", cause: nil
149
235
  end
150
236
  end
237
+ if key_pem && rotation
238
+ begin
239
+ valid_pair = OpenSSL::X509::Certificate.new(cert_pem).check_private_key(key)
240
+ rescue OpenSSL::OpenSSLError, ArgumentError
241
+ valid_pair = false
242
+ end
243
+ unless valid_pair
244
+ raise TlsFileError, "invalid gRPC TLS client certificate/key pair '#{transport.cert_path}' and '#{transport.private_key_path}'", cause: nil
245
+ end
246
+ end
247
+ key_pem = key.to_pem if key_pem && transport.private_key_passphrase
151
248
  GRPC::Core::ChannelCredentials.new(ca_pem, key_pem, cert_pem)
152
249
  end
153
250
 
251
+ def read_tls_files
252
+ @tls_paths.transform_values { |path| read_pem(path) }
253
+ end
254
+
255
+ def fingerprints(pems)
256
+ pems.transform_values { |pem| Digest::SHA256.hexdigest(pem) }
257
+ end
258
+
154
259
  def read_pem(path)
155
260
  return nil unless path
156
261
 
157
- File.read(path)
262
+ File.binread(path)
158
263
  rescue SystemCallError, IOError
159
- raise Fluent::ConfigError, "could not read gRPC TLS file '#{path}'", cause: nil
264
+ raise TlsFileError, "could not read gRPC TLS file '#{path}'", cause: nil
160
265
  end
161
266
  end
@@ -8,7 +8,7 @@
8
8
  module Fluent
9
9
  module Plugin
10
10
  module OpentelemetryRadiant
11
- VERSION = "0.1.0"
11
+ VERSION = "0.1.1"
12
12
  end
13
13
  end
14
14
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: fluent-plugin-opentelemetry-radiant
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.1.1
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-10-27 00:00:00.000000000 Z
11
+ date: 2026-10-28 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: async-http