"""
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