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