Source code for ruxit_haproxy

"""
OneAgent HAProxy plugin
=======================

Gathers HAProxy stats served by the load balancer as a csv. The full description of stats is
available at https://cbonte.github.io/haproxy-dconv/configuration-1.5.html#9

Configuration includes:

 * credentials, if stats are guarded by HTTP basic-auth.
 * url to stats page - ";csv" will be automatically appended if missing. If url is absolute, it will
   remain so, if it is relative, it will be joined with "http://localhost" prefix

An example HAProxy stats csv::

    # pxname,svname,qcur,qmax,scur,smax,slim,stot,bin,bout,dreq,dresp,ereq,econ,eresp,wretr,wredis,status,weight,act,bck,chkfail,chkdown,lastchg,downtime,qlimit,pid,iid,sid,throttle,lbtot,tracked,type,rate,rate_lim,rate_max,check_status,check_code,check_duration,hrsp_1xx,hrsp_2xx,hrsp_3xx,hrsp_4xx,hrsp_5xx,hrsp_other,hanafail,req_rate,req_rate_max,req_tot,cli_abrt,srv_abrt,comp_in,comp_out,comp_byp,comp_rsp,lastsess,last_chk,last_agt,qtime,ctime,rtime,ttime,
    haproxy-backend,server_behind_haproxy,0,0,0,1,,27,8180,41254,,0,,0,0,0,0,no check,1,1,0,,,,,,1,1,1,,27,,2,0,,2,,,,0,8,0,19,3,0,0,,,,0,0,,,,,351293,,,0,1,2,13,
    haproxy-backend,BACKEND,0,0,0,1,100,21962,5579929,25660881,0,0,,0,0,0,0,UP,1,1,0,,0,2863759,0,,1,1,0,,27,,1,0,,2,,,,0,21940,0,22,0,0,,,,,0,0,0,0,0,0,78967,,,0,0,0,1,
    haproxy-frontend,FRONTEND,,,1,3,1000,23283,5885422,27269302,0,0,3,,,,,OPEN,,,,,,,,,1,2,0,,,,0,1,0,3,,,,0,23257,0,25,0,0,,1,3,23283,,,0,0,0,0,,,,,,,,
    stats,FRONTEND,,,0,0,2000,0,0,0,0,0,0,,,,,OPEN,,,,,,,,,1,3,0,,,,0,0,0,0,,,,0,0,0,0,0,0,,0,0,0,,,0,0,0,0,,,,,,,,

The measurements are gathered following few rules:

 * each measurement is associated with service dimension. This allows sending multiple values of the
   same metric (for example reqest rate) under the same key, just changing the dimension.
 * rows where svname=BACKEND are ignored. In the example above "haproxy-backend,BACKEND" would be ignored
 * rows where svname=FRONTEND are associated with service dimension set to the value of pxname.
   In the example above, this would apply to "haproxy-frontend,FRONTEND" and "stats,FRONTEND" rows
 * rows where svname is neither BACKEND/FRONTEND are associated with service pxname-svname.
   In the example above, this would apply to "haproxy-backend,server_behind_haproxy" row setting
   the dimensions to {service: haproxy-backend-server_behind_haproxy}
 * measurement keys specified in json mostly mirror the ones specified in HAProxy docs, with additional
   `be_` or `fe_` prefix to indicate whether they apply to frontends or backends

Limitations:

 * HAProxy needs to be configured to serve statistics on a particular url, which might not be enabled by
   default.
 * Multiple HAProxy worker processes running on the same configuration is not supported. Except for using
   HAProxy for terminating SSL traffic, such configuration is not recommended. It also breaks stats if
   not configured properly (each server having it's own unique url/port for stats) as by default, random
   HAProxy instance would answer the stats request without indicating which one it is.
 * One instance of the plugin is created for each detected HAProxy process group instance. However
   current ruxit agent implementations merges all HAProxy processes into one process group, which means
   this effectively is a singleton plugin.
 * SSL certificate verification is disabled.

"""
import requests
import requests.exceptions
import csv
import urllib.parse
from ruxit.api.data import PluginMeasurement
from ruxit.api.base_plugin import BasePlugin
from ruxit.api.exceptions import AuthException, ConfigException


[docs]class HaProxyPlugin(BasePlugin): """ HAProxy plugin class. This plugin is stateful, however, the state is limited to some processing of received configuration, and no resources are acquired. """ _DEFAULT_TIMEOUT_SECONDS = 2 _ABSOLUTE_METRICS = {'req_rate', 'scur', 'qcur'} _NO_PREFIX_METRICS = {'hrsp_4xx', 'hrsp_5xx'}
[docs] def initialize(self, **kwargs): """ Plugin initialization is limited to parsing configuration in order to construct url and auth parameters for later HTTP requests. """ entity = kwargs["associated_entity"] if len(entity.processes) > 1: raise ConfigException('Detected multiple HAProxy instances') config = kwargs['config'] if config['url'] == '': raise ConfigException("URL is empty") self.url = urllib.parse.urljoin('http://localhost', config['url']) if not self.url.endswith(';csv'): self.url += ";csv" self.timeout = config.get('timeout', self._DEFAULT_TIMEOUT_SECONDS) self.auth = None if 'auth_user' in config and 'auth_password' in config: self.auth = (config['auth_user'], config['auth_password']) self.verify = False
[docs] def query(self, **kwargs): """ Tries to gather as much data as possible without raising errors - as long as a csv is received with enough information to extract proxy and server names, but some metrics missing - no errors are raised. This is to prevent unnecessary problems when different HAProxy versions provide different sets of metrics. Raises: ruxit.api.exceptions.AuthException: HAProxy responded with 401 ruxit.api.exceptions.ConfigException: connection and data parsing errors """ try: response = requests.get( self.url, auth=self.auth, verify=self.verify, timeout=self.timeout) except (requests.exceptions.MissingSchema, requests.exceptions.InvalidSchema, requests.exceptions.InvalidURL) as ex: raise ConfigException('URL: "%s" does not appear to be valid' % self.url) from ex except requests.exceptions.ConnectTimeout as ex: raise ConfigException('Timeout on connecting with "%s"' % self.url) from ex except requests.exceptions.RequestException as ex: raise ConfigException('Unable to connect to "%s"' % self.url) from ex if response.status_code == requests.codes.UNAUTHORIZED: raise AuthException(response) stats_csv_rows = [row for row in csv.DictReader(response.content.decode().splitlines())] if len(stats_csv_rows) == 0: raise ConfigException('Content from "%s" does not appear to be in haproxy stats format' % self.url) try: for row in stats_csv_rows: pxname = row['# pxname'] svname = row['svname'] if svname == 'FRONTEND': metric_prefix = "fe_" dimensions = {'service': pxname} prefix_metrics = {'ereq', 'scur', 'susage', 'req_rate', 'bin', 'bout'} elif svname == 'BACKEND': continue else: metric_prefix = 'be_' dimensions = {'service': '%s-%s' % (pxname, svname)} prefix_metrics = {'econ', 'eresp', 'qcur', 'scur', 'susage', 'bin', 'bout'} all_metrics = prefix_metrics.union(self._NO_PREFIX_METRICS) for metric in all_metrics: if metric in row: metric_value = row[metric] if metric_value != '': metric_json_name = metric_prefix + metric if metric in prefix_metrics else metric if metric in self._ABSOLUTE_METRICS: self.results_builder.add_absolute_result( PluginMeasurement(dimensions=dimensions, key=metric_json_name, value=metric_value) ) else: self.results_builder.add_relative_result( PluginMeasurement(dimensions=dimensions, key=metric_json_name, value=metric_value) ) elif metric == 'susage': try: scur = row['scur'] slim = row['slim'] if scur == '' or slim == '': continue else: metric_json_name = metric_prefix + metric self.results_builder.add_absolute_result( PluginMeasurement(dimensions=dimensions, key=metric_json_name, value=(float(scur)/float(slim))*100) ) except KeyError: pass except KeyError as ex: raise ConfigException('Content from "%s" does not appear to be in haproxy stats format' % self.url) from ex