fluent-plugin-opentelemetry-radiant 0.1.0

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.
Files changed (35) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +50 -0
  3. data/LICENSE +202 -0
  4. data/NOTICE +23 -0
  5. data/README.md +278 -0
  6. data/lib/fluent/plugin/in_opentelemetry.rb +118 -0
  7. data/lib/fluent/plugin/in_opentelemetry_metrics.rb +285 -0
  8. data/lib/fluent/plugin/opentelemetry/constant.rb +35 -0
  9. data/lib/fluent/plugin/opentelemetry/grpc_input_handler.rb +89 -0
  10. data/lib/fluent/plugin/opentelemetry/grpc_output_handler.rb +161 -0
  11. data/lib/fluent/plugin/opentelemetry/http_input_handler.rb +177 -0
  12. data/lib/fluent/plugin/opentelemetry/http_output_handler.rb +157 -0
  13. data/lib/fluent/plugin/opentelemetry/log_record_builder.rb +229 -0
  14. data/lib/fluent/plugin/opentelemetry/output_response.rb +21 -0
  15. data/lib/fluent/plugin/opentelemetry/request.rb +67 -0
  16. data/lib/fluent/plugin/opentelemetry/response.rb +90 -0
  17. data/lib/fluent/plugin/opentelemetry/version.rb +9 -0
  18. data/lib/fluent/plugin/opentelemetry_radiant/version.rb +14 -0
  19. data/lib/fluent/plugin/out_opentelemetry.rb +11 -0
  20. data/lib/fluent/plugin/out_opentelemetry_radiant.rb +205 -0
  21. data/lib/opentelemetry/proto/collector/logs/v1/logs_service_pb.rb +27 -0
  22. data/lib/opentelemetry/proto/collector/logs/v1/logs_service_services_pb.rb +48 -0
  23. data/lib/opentelemetry/proto/collector/metrics/v1/metrics_service_pb.rb +27 -0
  24. data/lib/opentelemetry/proto/collector/metrics/v1/metrics_service_services_pb.rb +48 -0
  25. data/lib/opentelemetry/proto/collector/profiles/v1development/profiles_service_pb.rb +27 -0
  26. data/lib/opentelemetry/proto/collector/profiles/v1development/profiles_service_services_pb.rb +47 -0
  27. data/lib/opentelemetry/proto/collector/trace/v1/trace_service_pb.rb +27 -0
  28. data/lib/opentelemetry/proto/collector/trace/v1/trace_service_services_pb.rb +48 -0
  29. data/lib/opentelemetry/proto/common/v1/common_pb.rb +26 -0
  30. data/lib/opentelemetry/proto/logs/v1/logs_pb.rb +29 -0
  31. data/lib/opentelemetry/proto/metrics/v1/metrics_pb.rb +41 -0
  32. data/lib/opentelemetry/proto/profiles/v1development/profiles_pb.rb +36 -0
  33. data/lib/opentelemetry/proto/resource/v1/resource_pb.rb +23 -0
  34. data/lib/opentelemetry/proto/trace/v1/trace_pb.rb +33 -0
  35. metadata +198 -0
@@ -0,0 +1,177 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fluent/plugin/opentelemetry/constant"
4
+ require "fluent/plugin/opentelemetry/request"
5
+ require "fluent/plugin/opentelemetry/response"
6
+ require "google/protobuf"
7
+ require "zlib"
8
+ require "stringio"
9
+
10
+ module Fluent::PluginHelper::HttpServer
11
+ module Extension
12
+ refine Request do
13
+ # This API was introduced at fluentd v1.19.0.
14
+ # Ref. https://github.com/fluent/fluentd/pull/4903
15
+ # If we have supported v1.19.0+ only, we can remove this patch.
16
+ unless method_defined?(:headers)
17
+ def headers
18
+ @request.headers
19
+ end
20
+ end
21
+
22
+ # Workaround for fluentd v1.19.1 or earlier which does not close request body.
23
+ # Ref. https://github.com/fluent/fluentd/pull/5231
24
+ unless method_defined?(:close)
25
+ def close
26
+ @request.body&.close
27
+ end
28
+ end
29
+
30
+ unless method_defined?(:body_stream)
31
+ def body_stream
32
+ @request.body
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
38
+
39
+ class Fluent::Plugin::Opentelemetry::HttpInputHandler
40
+ class SizeLimitError < StandardError; end
41
+
42
+ using Fluent::PluginHelper::HttpServer::Extension
43
+
44
+ def initialize(http_config, logger)
45
+ @http_config = http_config
46
+ @logger = logger
47
+ end
48
+
49
+ def logs(req, &block)
50
+ common(req, Fluent::Plugin::Opentelemetry::Request::Logs, Fluent::Plugin::Opentelemetry::Response::Logs, &block)
51
+ end
52
+
53
+ def metrics(req, &block)
54
+ common(req, Fluent::Plugin::Opentelemetry::Request::Metrics, Fluent::Plugin::Opentelemetry::Response::Metrics, &block)
55
+ end
56
+
57
+ def traces(req, &block)
58
+ common(req, Fluent::Plugin::Opentelemetry::Request::Traces, Fluent::Plugin::Opentelemetry::Response::Traces, &block)
59
+ end
60
+
61
+ private
62
+
63
+ def common(req, request_class, response_class)
64
+ content_type = req.headers["content-type"]
65
+ content_encoding = Array(req.headers["content-encoding"]).first
66
+
67
+ content_length = Array(req.headers["content-length"]).first&.to_i
68
+ if content_length && content_length > @http_config.body_size_limit
69
+ @logger.warn { "Received too big content length: #{content_length}" }
70
+ return response_payload_too_large
71
+ end
72
+
73
+ begin
74
+ body = read_body(req, limit: @http_config.body_size_limit)
75
+ rescue SizeLimitError
76
+ @logger.warn { "Received payload exceeding body_size_limit" }
77
+ return response_payload_too_large
78
+ end
79
+
80
+ return response_unsupported_media_type unless valid_content_type?(content_type)
81
+ return response_bad_request(content_type) unless valid_content_encoding?(content_encoding)
82
+
83
+ if content_encoding == Fluent::Plugin::Opentelemetry::CONTENT_ENCODING_GZIP
84
+ begin
85
+ body = decompress(body, limit: @http_config.decompression_size_limit)
86
+ rescue SizeLimitError
87
+ @logger.warn { "Decompressed payload exceeding decompression_size_limit" }
88
+ return response_payload_too_large
89
+ rescue Zlib::Error, EOFError => e
90
+ @logger.warn { "Failed to decompress gzip payload: #{e.message}" }
91
+ return response_bad_request(content_type)
92
+ end
93
+ end
94
+
95
+ begin
96
+ record = request_class.new(body).record
97
+ rescue Google::Protobuf::ParseError => e
98
+ # The format in request body does not comply with the OpenTelemetry protocol.
99
+ @logger.warn { "Failed to parse OpenTelemetry payload: #{e.message}" }
100
+ return response_bad_request(content_type)
101
+ end
102
+
103
+ yield record
104
+
105
+ res = response_class.new
106
+ response(200, content_type, res.body(type: Fluent::Plugin::Opentelemetry::Response.type(content_type)))
107
+ ensure
108
+ req.close
109
+ end
110
+
111
+ def read_body(request, limit:)
112
+ body = +"".b
113
+ while (chunk = request.body_stream&.read)
114
+ body << chunk
115
+ if body.bytesize > limit
116
+ raise SizeLimitError, "Too large payload"
117
+ end
118
+ end
119
+ body
120
+ end
121
+
122
+ BYTES_TO_READ = 64 * 1024
123
+
124
+ def decompress(compressed_data, limit:)
125
+ io = StringIO.new(compressed_data)
126
+ out = +"".b
127
+ loop do
128
+ reader = Zlib::GzipReader.new(io)
129
+ while (chunk = reader.read(BYTES_TO_READ))
130
+ out << chunk
131
+ if out.bytesize > limit
132
+ raise SizeLimitError, "Decompressed data exceeds limit of #{limit} bytes"
133
+ end
134
+ end
135
+
136
+ unused = reader.unused
137
+ reader.finish
138
+ unless unused.nil?
139
+ adjust = unused.length
140
+ io.pos -= adjust
141
+ end
142
+ break if io.eof?
143
+ end
144
+ out
145
+ end
146
+
147
+ def valid_content_type?(content_type)
148
+ case content_type
149
+ when Fluent::Plugin::Opentelemetry::CONTENT_TYPE_PROTOBUF, Fluent::Plugin::Opentelemetry::CONTENT_TYPE_JSON
150
+ true
151
+ else
152
+ false
153
+ end
154
+ end
155
+
156
+ def valid_content_encoding?(content_encoding)
157
+ return true if content_encoding.nil?
158
+
159
+ content_encoding == Fluent::Plugin::Opentelemetry::CONTENT_ENCODING_GZIP
160
+ end
161
+
162
+ def response(code, content_type, body)
163
+ [code, { Fluent::Plugin::Opentelemetry::CONTENT_TYPE => content_type }, body]
164
+ end
165
+
166
+ def response_unsupported_media_type
167
+ response(415, Fluent::Plugin::Opentelemetry::CONTENT_TYPE_PLAIN, "415 unsupported media type, supported: [application/json, application/x-protobuf]")
168
+ end
169
+
170
+ def response_payload_too_large
171
+ response(413, Fluent::Plugin::Opentelemetry::CONTENT_TYPE_PLAIN, "413 Payload Too Large")
172
+ end
173
+
174
+ def response_bad_request(content_type)
175
+ response(400, content_type, "") # TODO: fix body message
176
+ end
177
+ end
@@ -0,0 +1,157 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Copyright 2025- Shizuo Fujita
4
+ # Copyright 2026 Radiant Logic, Inc.
5
+ #
6
+ # Licensed under the Apache License, Version 2.0 (the "License");
7
+ # you may not use this file except in compliance with the License.
8
+ #
9
+ # Modified by Radiant Logic, Inc. to export prepared OTLP logs requests while
10
+ # retaining the upstream relay and response-handling paths.
11
+
12
+ require "fluent/plugin/opentelemetry/constant"
13
+ require "fluent/plugin/opentelemetry/request"
14
+ require "fluent/plugin/opentelemetry/output_response"
15
+
16
+ require "excon"
17
+ require "google/protobuf"
18
+ require "stringio"
19
+ require "zlib"
20
+
21
+ class Fluent::Plugin::Opentelemetry::HttpOutputHandler
22
+ include Fluent::Plugin::Opentelemetry::OutputResponse
23
+
24
+ def initialize(http_config, transport_config, logger, headers = {})
25
+ @http_config = http_config
26
+ @logger = logger
27
+ @headers = headers
28
+ @endpoint = http_config.endpoint.sub(%r{/+\z}, "")
29
+
30
+ tls_settings = {}
31
+ if transport_config.protocol == :tls
32
+ tls_settings[:ssl_ca_file] = transport_config.ca_path if transport_config.ca_path
33
+ tls_settings[:client_cert] = transport_config.cert_path
34
+ tls_settings[:client_key] = transport_config.private_key_path
35
+ tls_settings[:client_key_pass] = transport_config.private_key_passphrase
36
+ tls_settings[:ssl_min_version] = Fluent::Plugin::Opentelemetry::TLS_VERSIONS_MAP[transport_config.min_version]
37
+ tls_settings[:ssl_max_version] = Fluent::Plugin::Opentelemetry::TLS_VERSIONS_MAP[transport_config.max_version]
38
+ tls_settings[:ssl_verify_peer] = !transport_config.insecure
39
+ end
40
+
41
+ timeout_settings = {
42
+ read_timeout: http_config.read_timeout,
43
+ write_timeout: http_config.write_timeout,
44
+ connect_timeout: http_config.connect_timeout
45
+ }
46
+ @connections = {
47
+ Fluent::Plugin::Opentelemetry::RECORD_TYPE_LOGS => http_logs_endpoint,
48
+ Fluent::Plugin::Opentelemetry::RECORD_TYPE_METRICS => http_metrics_endpoint,
49
+ Fluent::Plugin::Opentelemetry::RECORD_TYPE_TRACES => http_traces_endpoint
50
+ }.transform_values do |uri|
51
+ Excon.new(uri, proxy: http_config.proxy, persistent: true, **tls_settings, **timeout_settings)
52
+ end
53
+ end
54
+
55
+ def export(record)
56
+ uri, headers, body = get_post_data(record)
57
+ post(uri, @connections.fetch(record["type"]), headers: headers, body: body,
58
+ logs: record["type"] == Fluent::Plugin::Opentelemetry::RECORD_TYPE_LOGS)
59
+ end
60
+
61
+ def export_logs(request)
62
+ headers, body = prepare_body(request.to_proto)
63
+ post(http_logs_endpoint, @connections.fetch(Fluent::Plugin::Opentelemetry::RECORD_TYPE_LOGS),
64
+ headers: headers, body: body, logs: true)
65
+ end
66
+
67
+ private
68
+
69
+ def post(uri, connection, **options)
70
+ logs = options.delete(:logs)
71
+ response = connection.post(**options)
72
+ # Read every response, including errors, before reusing the connection.
73
+ body = response.body
74
+
75
+ if (200...300).cover?(response.status)
76
+ check_logs_response(body, response.headers) if logs && body && !body.empty?
77
+ return
78
+ end
79
+
80
+ if response.status == 400
81
+ # The client MUST NOT retry HTTP 400 Bad Request.
82
+ raise Fluent::UnrecoverableError, "got unrecoverable error response from '#{uri}', response code is #{response.status}"
83
+ end
84
+
85
+ if @http_config.retryable_response_codes&.include?(response.status)
86
+ raise Fluent::Plugin::OpentelemetryOutput::RetryableResponse, "got retryable error response from '#{uri}', response code is #{response.status}"
87
+ end
88
+ if @http_config.error_response_as_unrecoverable
89
+ raise Fluent::UnrecoverableError, "got unrecoverable error response from '#{uri}', response code is #{response.status}"
90
+ else
91
+ @logger.error "got error response from '#{uri}', response code is #{response.status}"
92
+ end
93
+ rescue Excon::Error => e
94
+ message = redact_headers(e.message)
95
+ raise if message == e.message
96
+
97
+ # Fluentd logs retry errors; a backend or proxy may echo request headers.
98
+ raise Fluent::Plugin::OpentelemetryOutput::RetryableResponse, message, cause: nil
99
+ end
100
+
101
+ def check_logs_response(body, headers)
102
+ content_type = headers.find { |key, _| key.downcase == "content-type" }&.last.to_s.split(";").first
103
+ response_class = Opentelemetry::Proto::Collector::Logs::V1::ExportLogsServiceResponse
104
+ response = if content_type == Fluent::Plugin::Opentelemetry::CONTENT_TYPE_JSON
105
+ response_class.decode_json(body)
106
+ else
107
+ response_class.decode(body)
108
+ end
109
+ warn_partial_success(response)
110
+ rescue Google::Protobuf::ParseError
111
+ # A successful HTTP status acknowledges delivery even if its body is invalid.
112
+ @logger.warn "Could not decode successful OTLP logs response; delivery will not be retried"
113
+ end
114
+
115
+ def http_logs_endpoint
116
+ "#{@endpoint}/v1/logs"
117
+ end
118
+
119
+ def http_metrics_endpoint
120
+ "#{@endpoint}/v1/metrics"
121
+ end
122
+
123
+ def http_traces_endpoint
124
+ "#{@endpoint}/v1/traces"
125
+ end
126
+
127
+ def get_post_data(record)
128
+ uri, request_class = case record["type"]
129
+ when Fluent::Plugin::Opentelemetry::RECORD_TYPE_LOGS
130
+ [http_logs_endpoint, Fluent::Plugin::Opentelemetry::Request::Logs]
131
+ when Fluent::Plugin::Opentelemetry::RECORD_TYPE_METRICS
132
+ [http_metrics_endpoint, Fluent::Plugin::Opentelemetry::Request::Metrics]
133
+ when Fluent::Plugin::Opentelemetry::RECORD_TYPE_TRACES
134
+ [http_traces_endpoint, Fluent::Plugin::Opentelemetry::Request::Traces]
135
+ else
136
+ raise Fluent::UnrecoverableError, "got unknown record type '#{record['type']}'"
137
+ end
138
+ headers, body = prepare_body(request_class.new(record["message"]).body)
139
+ [uri, headers, body]
140
+ rescue Google::Protobuf::ParseError => e
141
+ raise Fluent::UnrecoverableError, e.message
142
+ end
143
+
144
+ def prepare_body(body)
145
+ # Header names are case-insensitive. Neither reserved header is user-settable,
146
+ # including Content-Encoding when compression is disabled.
147
+ headers = @headers.reject { |key, _| %w[content-type content-encoding].include?(key.downcase) }
148
+ headers[Fluent::Plugin::Opentelemetry::CONTENT_TYPE] = Fluent::Plugin::Opentelemetry::CONTENT_TYPE_PROTOBUF
149
+ if @http_config.compress == :gzip
150
+ headers[Fluent::Plugin::Opentelemetry::CONTENT_ENCODING] = Fluent::Plugin::Opentelemetry::CONTENT_ENCODING_GZIP
151
+ gz = Zlib::GzipWriter.new(StringIO.new)
152
+ gz << body
153
+ body = gz.close.string
154
+ end
155
+ [headers, body]
156
+ end
157
+ end
@@ -0,0 +1,229 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Copyright 2025- Shizuo Fujita
4
+ # Copyright 2026 Radiant Logic, Inc.
5
+ #
6
+ # Licensed under the Apache License, Version 2.0 (the "License");
7
+ # you may not use this file except in compliance with the License.
8
+ #
9
+ # This file was added by Radiant Logic, Inc. to convert ordinary Fluentd
10
+ # records into OpenTelemetry Protocol log export requests.
11
+
12
+ require "fluent/plugin/opentelemetry/constant"
13
+ require "opentelemetry/proto/collector/logs/v1/logs_service_pb"
14
+
15
+ require "json"
16
+
17
+ class Fluent::Plugin::Opentelemetry::LogRecordBuilder
18
+ SEVERITY_NUMBERS = {
19
+ "TRACE" => 1,
20
+ "DEBUG" => 5,
21
+ "INFO" => 9,
22
+ "WARN" => 13,
23
+ "ERROR" => 17,
24
+ "FATAL" => 21
25
+ }.freeze
26
+
27
+ INT64_RANGE = (-(2**63)..((2**63) - 1)).freeze
28
+
29
+ def initialize(body_key:, body_format:, severity_key:, attributes_from_record:, exclude_keys:,
30
+ resource_attributes:, resource_attributes_from_record:, scope_name:, clock: nil)
31
+ @body_key = body_key
32
+ @body_format = body_format
33
+ @severity_key = severity_key
34
+ @attributes_from_record = attributes_from_record
35
+ @exclude_keys = exclude_keys.map(&:to_s).freeze
36
+ @resource_attributes = resource_attributes
37
+ @resource_attributes_from_record = resource_attributes_from_record
38
+ @scope_name = scope_name
39
+ @clock = clock || method(:current_time_unix_nano)
40
+ end
41
+
42
+ def build(tag, events)
43
+ raise ArgumentError, "events must not be empty" if events.empty?
44
+
45
+ observed_time_unix_nano = @clock.call
46
+ log_records = events.map do |time, record|
47
+ build_log_record(tag, time, record, observed_time_unix_nano)
48
+ end
49
+
50
+ collector_logs_v1::ExportLogsServiceRequest.new(
51
+ resource_logs: [
52
+ logs_v1::ResourceLogs.new(
53
+ resource: resource_v1::Resource.new(attributes: resource_attributes(events.first[1])),
54
+ scope_logs: [
55
+ logs_v1::ScopeLogs.new(
56
+ scope: common_v1::InstrumentationScope.new(name: utf8_string(@scope_name)),
57
+ log_records: log_records
58
+ )
59
+ ]
60
+ )
61
+ ]
62
+ )
63
+ end
64
+
65
+ private
66
+
67
+ def build_log_record(tag, time, record, observed_time_unix_nano)
68
+ values = {
69
+ time_unix_nano: event_time_unix_nano(time),
70
+ observed_time_unix_nano: observed_time_unix_nano,
71
+ body: body(record),
72
+ attributes: log_attributes(tag, record)
73
+ }
74
+
75
+ severity_present, severity = record_value(record, @severity_key)
76
+ if severity_present
77
+ values[:severity_text] = utf8_string(severity.to_s)
78
+ values[:severity_number] = SEVERITY_NUMBERS.fetch(values[:severity_text].upcase, 0)
79
+ end
80
+
81
+ logs_v1::LogRecord.new(**values)
82
+ end
83
+
84
+ def body(record)
85
+ if @body_format == :kvlist
86
+ return common_v1::AnyValue.new(
87
+ kvlist_value: common_v1::KeyValueList.new(values: key_values(record))
88
+ )
89
+ end
90
+
91
+ body_present, body_value = record_value(record, @body_key)
92
+ text = body_present ? body_value.to_s : JSON.generate(normalize_json_strings(record))
93
+ common_v1::AnyValue.new(string_value: utf8_string(text))
94
+ end
95
+
96
+ def log_attributes(tag, record)
97
+ attributes = {}
98
+ if @attributes_from_record
99
+ record.each do |key, value|
100
+ attribute_name = key.to_s
101
+ next if attribute_name == @body_key
102
+ next if attribute_name == @severity_key
103
+ next if @exclude_keys.include?(attribute_name)
104
+ next if attribute_name == "fluent.tag"
105
+
106
+ attributes[attribute_name] = value
107
+ end
108
+ end
109
+ attributes["fluent.tag"] = tag
110
+ key_values(attributes)
111
+ end
112
+
113
+ def resource_attributes(first_record)
114
+ attributes = @resource_attributes.each_with_object({}) do |(key, value), result|
115
+ result[key.to_s] = value
116
+ end
117
+
118
+ @resource_attributes_from_record.each do |attribute_name, record_key|
119
+ present, value = record_value(first_record, record_key.to_s)
120
+ attributes[attribute_name.to_s] = value if present
121
+ end
122
+
123
+ key_values(attributes)
124
+ end
125
+
126
+ def key_values(values)
127
+ values.map do |key, value|
128
+ common_v1::KeyValue.new(key: utf8_string(key.to_s), value: any_value(value))
129
+ end
130
+ end
131
+
132
+ def any_value(value)
133
+ case value
134
+ when String
135
+ common_v1::AnyValue.new(string_value: utf8_string(value))
136
+ when Integer
137
+ if INT64_RANGE.cover?(value)
138
+ common_v1::AnyValue.new(int_value: value)
139
+ else
140
+ common_v1::AnyValue.new(string_value: utf8_string(value.to_s))
141
+ end
142
+ when Float
143
+ common_v1::AnyValue.new(double_value: value)
144
+ when TrueClass, FalseClass
145
+ common_v1::AnyValue.new(bool_value: value)
146
+ when Hash
147
+ common_v1::AnyValue.new(
148
+ kvlist_value: common_v1::KeyValueList.new(values: key_values(value))
149
+ )
150
+ when Array
151
+ common_v1::AnyValue.new(
152
+ array_value: common_v1::ArrayValue.new(values: value.map { |item| any_value(item) })
153
+ )
154
+ when NilClass
155
+ common_v1::AnyValue.new(string_value: "null")
156
+ else
157
+ common_v1::AnyValue.new(string_value: utf8_string(value.to_s))
158
+ end
159
+ end
160
+
161
+ def utf8_string(string)
162
+ return string if string.encoding == Encoding::UTF_8 && string.valid_encoding?
163
+
164
+ string = if string.encoding == Encoding::ASCII_8BIT
165
+ string.dup.force_encoding(Encoding::UTF_8)
166
+ elsif string.encoding != Encoding::UTF_8
167
+ string.encode(Encoding::UTF_8, invalid: :replace, undef: :replace, replace: "\uFFFD")
168
+ else
169
+ string
170
+ end
171
+ string.scrub("\uFFFD")
172
+ end
173
+
174
+ def normalize_json_strings(value)
175
+ case value
176
+ when String
177
+ utf8_string(value)
178
+ when Hash
179
+ value.each_with_object({}) do |(key, item), result|
180
+ normalized_key = key.is_a?(String) ? utf8_string(key) : key
181
+ result[normalized_key] = normalize_json_strings(item)
182
+ end
183
+ when Array
184
+ value.map { |item| normalize_json_strings(item) }
185
+ else
186
+ value
187
+ end
188
+ end
189
+
190
+ def record_value(record, key)
191
+ return [true, record[key]] if record.key?(key)
192
+
193
+ symbol_key = key.to_sym
194
+ return [true, record[symbol_key]] if record.key?(symbol_key)
195
+
196
+ [false, nil]
197
+ end
198
+
199
+ def event_time_unix_nano(time)
200
+ if time.is_a?(Time)
201
+ (time.to_i * 1_000_000_000) + time.nsec
202
+ elsif time.respond_to?(:sec) && time.respond_to?(:nsec)
203
+ (time.sec * 1_000_000_000) + time.nsec
204
+ else
205
+ time.to_i * 1_000_000_000
206
+ end
207
+ end
208
+
209
+ def current_time_unix_nano
210
+ now = Time.now
211
+ (now.to_i * 1_000_000_000) + now.nsec
212
+ end
213
+
214
+ def collector_logs_v1
215
+ ::Opentelemetry::Proto::Collector::Logs::V1
216
+ end
217
+
218
+ def logs_v1
219
+ ::Opentelemetry::Proto::Logs::V1
220
+ end
221
+
222
+ def common_v1
223
+ ::Opentelemetry::Proto::Common::V1
224
+ end
225
+
226
+ def resource_v1
227
+ ::Opentelemetry::Proto::Resource::V1
228
+ end
229
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fluent/plugin/opentelemetry/response"
4
+
5
+ module Fluent::Plugin::Opentelemetry::OutputResponse
6
+ private
7
+
8
+ def redact_headers(message)
9
+ @headers.values.reject(&:empty?).sort_by { |value| -value.length }.reduce(message.to_s) do |text, value|
10
+ text.gsub(value, "[REDACTED]")
11
+ end
12
+ end
13
+
14
+ def warn_partial_success(response)
15
+ partial = response&.partial_success
16
+ return unless partial && partial.rejected_log_records > 0
17
+
18
+ @logger.warn "OTLP logs partially accepted: rejected_log_records=#{partial.rejected_log_records}, " \
19
+ "error_message=#{redact_headers(partial.error_message)}"
20
+ end
21
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fluent/plugin/opentelemetry/constant"
4
+ require "opentelemetry/proto/collector/logs/v1/logs_service_pb"
5
+ require "opentelemetry/proto/collector/metrics/v1/metrics_service_pb"
6
+ require "opentelemetry/proto/collector/trace/v1/trace_service_pb"
7
+
8
+ require "google/protobuf"
9
+
10
+ class Fluent::Plugin::Opentelemetry::Request
11
+ class Logs
12
+ def initialize(body, ignore_unknown_fields: true)
13
+ @request =
14
+ if body.start_with?("{")
15
+ Opentelemetry::Proto::Collector::Logs::V1::ExportLogsServiceRequest.decode_json(body, ignore_unknown_fields: ignore_unknown_fields)
16
+ else
17
+ Opentelemetry::Proto::Collector::Logs::V1::ExportLogsServiceRequest.decode(body)
18
+ end
19
+ end
20
+
21
+ def body
22
+ @request.to_proto
23
+ end
24
+
25
+ def record
26
+ @request.to_json
27
+ end
28
+ end
29
+
30
+ class Metrics
31
+ def initialize(body, ignore_unknown_fields: true)
32
+ @request =
33
+ if body.start_with?("{")
34
+ Opentelemetry::Proto::Collector::Metrics::V1::ExportMetricsServiceRequest.decode_json(body, ignore_unknown_fields: ignore_unknown_fields)
35
+ else
36
+ Opentelemetry::Proto::Collector::Metrics::V1::ExportMetricsServiceRequest.decode(body)
37
+ end
38
+ end
39
+
40
+ def body
41
+ @request.to_proto
42
+ end
43
+
44
+ def record
45
+ @request.to_json
46
+ end
47
+ end
48
+
49
+ class Traces
50
+ def initialize(body, ignore_unknown_fields: true)
51
+ @request =
52
+ if body.start_with?("{")
53
+ Opentelemetry::Proto::Collector::Trace::V1::ExportTraceServiceRequest.decode_json(body, ignore_unknown_fields: ignore_unknown_fields)
54
+ else
55
+ Opentelemetry::Proto::Collector::Trace::V1::ExportTraceServiceRequest.decode(body)
56
+ end
57
+ end
58
+
59
+ def body
60
+ @request.to_proto
61
+ end
62
+
63
+ def record
64
+ @request.to_json
65
+ end
66
+ end
67
+ end