github.com/yankunsam/loki/v2@v2.6.3-0.20220817130409-389df5235c27/clients/cmd/fluentd/lib/fluent/plugin/out_loki.rb (about) 1 # frozen_string_literal: true 2 3 # 4 # Copyright 2018- Grafana Labs 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 # You may obtain a copy of the License at 9 # 10 # http://www.apache.org/licenses/LICENSE-2.0 11 # 12 # Unless required by applicable law or agreed to in writing, software 13 # distributed under the License is distributed on an "AS IS" BASIS, 14 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 # See the License for the specific language governing permissions and 16 # limitations under the License. 17 18 require 'fluent/env' 19 require 'fluent/plugin/output' 20 require 'net/http' 21 require 'yajl' 22 require 'time' 23 24 module Fluent 25 module Plugin 26 # Subclass of Fluent Plugin Output 27 class LokiOutput < Fluent::Plugin::Output # rubocop:disable Metrics/ClassLength 28 Fluent::Plugin.register_output('loki', self) 29 30 class LogPostError < StandardError; end 31 32 helpers :compat_parameters, :record_accessor 33 34 attr_accessor :record_accessors 35 36 DEFAULT_BUFFER_TYPE = 'memory' 37 38 desc 'Loki API base URL' 39 config_param :url, :string, default: 'https://logs-prod-us-central1.grafana.net' 40 41 desc 'Authentication: basic auth credentials' 42 config_param :username, :string, default: nil 43 config_param :password, :string, default: nil, secret: true 44 45 desc 'Authentication: Authorization header with Bearer token scheme' 46 config_param :bearer_token_file, :string, default: nil 47 48 desc 'TLS: parameters for presenting a client certificate' 49 config_param :cert, :string, default: nil 50 config_param :key, :string, default: nil 51 52 desc 'TLS: CA certificate file for server certificate verification' 53 config_param :ca_cert, :string, default: nil 54 55 desc 'TLS: disable server certificate verification' 56 config_param :insecure_tls, :bool, default: false 57 58 desc 'Loki tenant id' 59 config_param :tenant, :string, default: nil 60 61 desc 'extra labels to add to all log streams' 62 config_param :extra_labels, :hash, default: {} 63 64 desc 'format to use when flattening the record to a log line' 65 config_param :line_format, :enum, list: %i[json key_value], default: :key_value 66 67 desc 'extract kubernetes labels as loki labels' 68 config_param :extract_kubernetes_labels, :bool, default: false 69 70 desc 'comma separated list of needless record keys to remove' 71 config_param :remove_keys, :array, default: %w[], value_type: :string 72 73 desc 'if a record only has 1 key, then just set the log line to the value and discard the key.' 74 config_param :drop_single_key, :bool, default: false 75 76 desc 'whether or not to include the fluentd_thread label when multiple threads are used for flushing' 77 config_param :include_thread_label, :bool, default: true 78 79 config_section :buffer do 80 config_set_default :@type, DEFAULT_BUFFER_TYPE 81 config_set_default :chunk_keys, [] 82 end 83 84 # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity 85 def configure(conf) 86 compat_parameters_convert(conf, :buffer) 87 super 88 @uri = URI.parse("#{@url}/loki/api/v1/push") 89 unless @uri.is_a?(URI::HTTP) || @uri.is_a?(URI::HTTPS) 90 raise Fluent::ConfigError, 'URL parameter must have HTTP/HTTPS scheme' 91 end 92 93 @record_accessors = {} 94 conf.elements.select { |element| element.name == 'label' }.each do |element| 95 element.each_pair do |k, v| 96 element.has_key?(k) # rubocop:disable Style/PreferredHashMethods #to suppress unread configuration warning 97 v = k if v.empty? 98 @record_accessors[k] = record_accessor_create(v) 99 end 100 end 101 @remove_keys_accessors = [] 102 @remove_keys.each do |key| 103 @remove_keys_accessors.push(record_accessor_create(key)) 104 end 105 106 # If configured, load and validate client certificate (and corresponding key) 107 if client_cert_configured? 108 load_client_cert 109 validate_client_cert_key 110 end 111 112 if !@bearer_token_file.nil? && !File.exist?(@bearer_token_file) 113 raise "bearer_token_file #{@bearer_token_file} not found" 114 end 115 116 @auth_token_bearer = nil 117 unless @bearer_token_file.nil? 118 raise "bearer_token_file #{@bearer_token_file} not found" unless File.exist?(@bearer_token_file) 119 120 # Read the file once, assume long-lived authentication token. 121 @auth_token_bearer = File.read(@bearer_token_file) 122 raise "bearer_token_file #{@bearer_token_file} is empty" if @auth_token_bearer.empty? 123 124 log.info "will use Bearer token from bearer_token_file #{@bearer_token_file} in Authorization header" 125 end 126 127 raise "CA certificate file #{@ca_cert} not found" if !@ca_cert.nil? && !File.exist?(@ca_cert) 128 end 129 # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity 130 131 def client_cert_configured? 132 !@key.nil? && !@cert.nil? 133 end 134 135 def load_client_cert 136 @cert = OpenSSL::X509::Certificate.new(File.read(@cert)) if @cert 137 @key = OpenSSL::PKey.read(File.read(@key)) if @key 138 end 139 140 def validate_client_cert_key 141 if !@key.is_a?(OpenSSL::PKey::RSA) && !@key.is_a?(OpenSSL::PKey::DSA) 142 raise "Unsupported private key type #{key.class}" 143 end 144 end 145 146 def multi_workers_ready? 147 true 148 end 149 150 # flush a chunk to loki 151 def write(chunk) 152 # streams by label 153 payload = generic_to_loki(chunk) 154 body = { 'streams' => payload } 155 156 tenant = extract_placeholders(@tenant, chunk) if @tenant 157 158 # add ingest path to loki url 159 res = loki_http_request(body, tenant) 160 161 if res.is_a?(Net::HTTPSuccess) 162 log.debug "POST request was responded to with status code #{res.code}" 163 return 164 end 165 166 res_summary = "#{res.code} #{res.message} #{res.body}" 167 log.warn "failed to write post to #{@uri} (#{res_summary})" 168 log.debug Yajl.dump(body) 169 170 # Only retry 429 and 500s 171 raise(LogPostError, res_summary) if res.is_a?(Net::HTTPTooManyRequests) || res.is_a?(Net::HTTPServerError) 172 end 173 174 def http_request_opts(uri) 175 opts = { 176 use_ssl: uri.scheme == 'https' 177 } 178 179 # Optionally disable server server certificate verification. 180 if @insecure_tls 181 opts = opts.merge( 182 verify_mode: OpenSSL::SSL::VERIFY_NONE 183 ) 184 end 185 186 # Optionally present client certificate 187 if !@cert.nil? && !@key.nil? 188 opts = opts.merge( 189 cert: @cert, 190 key: @key 191 ) 192 end 193 194 # For server certificate verification: set custom CA bundle. 195 # Only takes effect when `insecure_tls` is not set. 196 unless @ca_cert.nil? 197 opts = opts.merge( 198 ca_file: @ca_cert 199 ) 200 end 201 opts 202 end 203 204 def generic_to_loki(chunk) 205 # log.debug("GenericToLoki: converting #{chunk}") 206 streams = chunk_to_loki(chunk) 207 payload_builder(streams) 208 end 209 210 private 211 212 def loki_http_request(body, tenant) 213 req = Net::HTTP::Post.new( 214 @uri.request_uri 215 ) 216 req.add_field('Content-Type', 'application/json') 217 req.add_field('Authorization', "Bearer #{@auth_token_bearer}") unless @auth_token_bearer.nil? 218 req.add_field('X-Scope-OrgID', tenant) if tenant 219 req.body = Yajl.dump(body) 220 req.basic_auth(@username, @password) if @username 221 222 opts = http_request_opts(@uri) 223 224 msg = "sending #{req.body.length} bytes to loki" 225 msg += " (tenant: \"#{tenant}\")" if tenant 226 log.debug msg 227 228 Net::HTTP.start(@uri.host, @uri.port, **opts) { |http| http.request(req) } 229 end 230 231 def numeric?(val) 232 !Float(val).nil? 233 rescue StandardError 234 false 235 end 236 237 def format_labels(data_labels) 238 formatted_labels = {} 239 # merge extra_labels with data_labels. If there are collisions extra_labels win. 240 data_labels = {} if data_labels.nil? 241 data_labels = data_labels.merge(@extra_labels) 242 # sanitize label values 243 data_labels.each { |k, v| formatted_labels[k] = v.gsub('"', '\\"') if v.is_a?(String) } 244 formatted_labels 245 end 246 247 def payload_builder(streams) 248 payload = [] 249 streams.each do |k, v| 250 # create a stream for each label set. 251 # Additionally sort the entries by timestamp just in case we 252 # got them out of order. 253 entries = v.sort_by.with_index { |hsh, i| [hsh['ts'], i] } 254 payload.push( 255 'stream' => format_labels(k), 256 'values' => entries.map { |e| [e['ts'].to_s, e['line']] } 257 ) 258 end 259 payload 260 end 261 262 def to_nano(time) 263 # time is a Fluent::EventTime object, or an Integer which represents unix timestamp (seconds from Epoch) 264 # https://docs.fluentd.org/plugin-development/api-plugin-output#chunk-each-and-block 265 if time.is_a?(Fluent::EventTime) 266 time.to_i * (10**9) + time.nsec 267 else 268 time.to_i * (10**9) 269 end 270 end 271 272 # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity 273 def record_to_line(record) 274 line = '' 275 if @drop_single_key && record.keys.length == 1 276 line = record[record.keys[0]] 277 else 278 case @line_format 279 when :json 280 line = Yajl.dump(record) 281 when :key_value 282 formatted_labels = [] 283 record.each do |k, v| 284 # Remove non UTF-8 characters by force-encoding the string 285 v = v.encode('utf-8', invalid: :replace, undef: :replace, replace: '?') if v.is_a?(String) 286 # Escape double quotes and backslashes by prefixing them with a backslash 287 v = v.to_s.gsub(/(["\\])/, '\\\\\1') 288 if v.include?(' ') || v.include?('=') 289 formatted_labels.push(%(#{k}="#{v}")) 290 else 291 formatted_labels.push(%(#{k}=#{v})) 292 end 293 end 294 line = formatted_labels.join(' ') 295 end 296 end 297 line 298 end 299 # rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity 300 301 # convert a line to loki line with labels 302 # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity 303 def line_to_loki(record) 304 chunk_labels = {} 305 line = '' 306 if record.is_a?(Hash) 307 @record_accessors&.each do |name, accessor| 308 new_key = name.gsub(%r{[.\-/]}, '_') 309 chunk_labels[new_key] = accessor.call(record) 310 accessor.delete(record) 311 end 312 313 if @extract_kubernetes_labels && record.key?('kubernetes') 314 kubernetes_labels = record['kubernetes']['labels'] 315 kubernetes_labels&.each_key do |l| 316 new_key = l.gsub(%r{[.\-/]}, '_') 317 chunk_labels[new_key] = kubernetes_labels[l] 318 end 319 end 320 321 # remove needless keys. 322 @remove_keys_accessors&.each do |deleter| 323 deleter.delete(record) 324 end 325 326 line = record_to_line(record) 327 else 328 line = record.to_s 329 end 330 331 # add buffer flush thread title as a label if there are multiple flush threads 332 # this prevents "entry out of order" errors in loki by making the label constellation 333 # unique per flush thread 334 # note that flush thread != fluentd worker. if you use multiple workers you still need to 335 # add the worker id as a label 336 if @include_thread_label && @buffer_config.flush_thread_count > 1 337 chunk_labels['fluentd_thread'] = Thread.current[:_fluentd_plugin_helper_thread_title].to_s 338 end 339 340 # return both the line content plus the labels found in the record 341 { 342 line: line, 343 labels: chunk_labels 344 } 345 end 346 # rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity 347 348 # iterate through each chunk and create a loki stream entry 349 def chunk_to_loki(chunk) 350 streams = {} 351 chunk.each do |time, record| 352 # each chunk has a unique set of labels 353 result = line_to_loki(record) 354 chunk_labels = result[:labels] 355 # initialize a new stream with the chunk_labels if it does not exist 356 streams[chunk_labels] = [] if streams[chunk_labels].nil? 357 # NOTE: timestamp must include nanoseconds 358 # append to matching chunk_labels key 359 streams[chunk_labels].push( 360 'ts' => to_nano(time), 361 'line' => result[:line] 362 ) 363 end 364 streams 365 end 366 end 367 end 368 end