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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +50 -0
- data/LICENSE +202 -0
- data/NOTICE +23 -0
- data/README.md +278 -0
- data/lib/fluent/plugin/in_opentelemetry.rb +118 -0
- data/lib/fluent/plugin/in_opentelemetry_metrics.rb +285 -0
- data/lib/fluent/plugin/opentelemetry/constant.rb +35 -0
- data/lib/fluent/plugin/opentelemetry/grpc_input_handler.rb +89 -0
- data/lib/fluent/plugin/opentelemetry/grpc_output_handler.rb +161 -0
- data/lib/fluent/plugin/opentelemetry/http_input_handler.rb +177 -0
- data/lib/fluent/plugin/opentelemetry/http_output_handler.rb +157 -0
- data/lib/fluent/plugin/opentelemetry/log_record_builder.rb +229 -0
- data/lib/fluent/plugin/opentelemetry/output_response.rb +21 -0
- data/lib/fluent/plugin/opentelemetry/request.rb +67 -0
- data/lib/fluent/plugin/opentelemetry/response.rb +90 -0
- data/lib/fluent/plugin/opentelemetry/version.rb +9 -0
- data/lib/fluent/plugin/opentelemetry_radiant/version.rb +14 -0
- data/lib/fluent/plugin/out_opentelemetry.rb +11 -0
- data/lib/fluent/plugin/out_opentelemetry_radiant.rb +205 -0
- data/lib/opentelemetry/proto/collector/logs/v1/logs_service_pb.rb +27 -0
- data/lib/opentelemetry/proto/collector/logs/v1/logs_service_services_pb.rb +48 -0
- data/lib/opentelemetry/proto/collector/metrics/v1/metrics_service_pb.rb +27 -0
- data/lib/opentelemetry/proto/collector/metrics/v1/metrics_service_services_pb.rb +48 -0
- data/lib/opentelemetry/proto/collector/profiles/v1development/profiles_service_pb.rb +27 -0
- data/lib/opentelemetry/proto/collector/profiles/v1development/profiles_service_services_pb.rb +47 -0
- data/lib/opentelemetry/proto/collector/trace/v1/trace_service_pb.rb +27 -0
- data/lib/opentelemetry/proto/collector/trace/v1/trace_service_services_pb.rb +48 -0
- data/lib/opentelemetry/proto/common/v1/common_pb.rb +26 -0
- data/lib/opentelemetry/proto/logs/v1/logs_pb.rb +29 -0
- data/lib/opentelemetry/proto/metrics/v1/metrics_pb.rb +41 -0
- data/lib/opentelemetry/proto/profiles/v1development/profiles_pb.rb +36 -0
- data/lib/opentelemetry/proto/resource/v1/resource_pb.rb +23 -0
- data/lib/opentelemetry/proto/trace/v1/trace_pb.rb +33 -0
- 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
|