diff --git a/kea_exporter/cli.py b/kea_exporter/cli.py index 05b70a5..d4cf5be 100644 --- a/kea_exporter/cli.py +++ b/kea_exporter/cli.py @@ -1,23 +1,16 @@ +import logging import sys import time import click -from prometheus_client import REGISTRY, make_wsgi_app, start_http_server +from prometheus_client import start_http_server +from prometheus_client.core import REGISTRY from kea_exporter import __project__, __version__ -from kea_exporter.exporter import Exporter +from kea_exporter.collector import KeaCollector +from kea_exporter.exporter import KeaExporter - -class Timer: - def __init__(self): - self.reset() - - def reset(self): - self.start_time = time.time() - - def time_elapsed(self): - now_time = time.time() - return now_time - self.start_time +logger = logging.getLogger(__name__) @click.command() @@ -38,12 +31,13 @@ def time_elapsed(self): help="Port that the exporter binds to.", ) @click.option( - "-i", - "--interval", - envvar="INTERVAL", - type=int, - default=0, - help="Minimal interval between two queries to Kea in seconds.", + "-d", + "--debug", + envvar="DEBUG", + type=bool, + default=False, + is_flag=True, + help="Run kea_exporter in debug mode.", ) @click.option( "--client-cert", @@ -61,31 +55,24 @@ def time_elapsed(self): ) @click.argument("targets", envvar="TARGETS", nargs=-1, required=True) @click.version_option(prog_name=__project__, version=__version__) -def cli(port, address, interval, **kwargs): - exporter = Exporter(**kwargs) - - if not exporter.targets: - sys.exit(1) - - httpd, _ = start_http_server(port, address) +def cli(port, address, debug, **kwargs): + if debug: + logging.basicConfig(level=logging.DEBUG) + else: + logging.basicConfig(level=logging.INFO) - t = Timer() + exporter = KeaExporter(**kwargs) - def local_wsgi_app(registry): - func = make_wsgi_app(registry, False) + start_http_server(port, address) - def app(environ, start_response): - if t.time_elapsed() >= interval: - exporter.update() - t.reset() - output_array = func(environ, start_response) - return output_array + if not exporter.targets: + sys.exit(1) - return app + collector = KeaCollector(exporter) - httpd.set_app(local_wsgi_app(REGISTRY)) + REGISTRY.register(collector) - click.echo(f"Listening on http://{address}:{port}") + logger.info(f"Listening on http://{address}:{port}") while True: time.sleep(1) diff --git a/kea_exporter/collector.py b/kea_exporter/collector.py new file mode 100644 index 0000000..83a151f --- /dev/null +++ b/kea_exporter/collector.py @@ -0,0 +1,21 @@ +from collections.abc import Iterator + +from prometheus_client.core import CounterMetricFamily, GaugeMetricFamily +from prometheus_client.registry import Collector + +from kea_exporter.exporter import KeaExporter +from kea_exporter.metrics import KeaMetrics + + +class KeaCollector(Collector): + def __init__(self, exporter: KeaExporter) -> None: + super().__init__() + self.exporter = exporter + + def collect(self) -> Iterator[GaugeMetricFamily]: + # Get all stats from the exporter. + all_stats = self.exporter.get_all_stats() + + kea_metrics = KeaMetrics(list(all_stats)) + + yield from kea_metrics.collect_metrics() diff --git a/kea_exporter/exporter.py b/kea_exporter/exporter.py index d5bd1da..e520427 100644 --- a/kea_exporter/exporter.py +++ b/kea_exporter/exporter.py @@ -1,46 +1,15 @@ -import re -import sys +import logging from urllib.parse import urlparse -import click -from prometheus_client import Gauge - -from kea_exporter import DHCPVersion from kea_exporter.http import KeaHTTPClient from kea_exporter.uds import KeaSocketClient +logger = logging.getLogger(__name__) -class Exporter: - subnet_pattern = re.compile( - r"^subnet\[(?P[\d]+)\]\.(pool\[(?P[\d]+)\]\.(?P[\w-]+)|(?P[\w-]+))$" - ) +class KeaExporter: def __init__(self, targets, **kwargs): # prometheus - self.prefix = "kea" - self.prefix_dhcp4 = f"{self.prefix}_dhcp4" - self.prefix_dhcp6 = f"{self.prefix}_dhcp6" - - self.metrics_dhcp4 = None - self.metrics_dhcp4_map = None - self.metrics_dhcp4_global_ignore = None - self.metrics_dhcp4_subnet_ignore = None - self.setup_dhcp4_metrics() - - self.metrics_dhcp6 = None - self.metrics_dhcp6_map = None - self.metrics_dhcp6_global_ignore = None - self.metrics_dhcp6_subnet_ignore = None - self.setup_dhcp6_metrics() - - # track unhandled metric keys, to notify only once - self.unhandled_metrics = set() - - # track missing info, to notify only once - self.subnet_missing_info_sent = { - DHCPVersion.DHCP4: [], - DHCPVersion.DHCP6: [], - } self.targets = [] for target in targets: @@ -52,524 +21,14 @@ def __init__(self, targets, **kwargs): elif url.path: client = KeaSocketClient(target, **kwargs) else: - click.echo(f"Unable to parse target argument: {target}") + logger.warning(f"Unable to parse target argument: {target}") continue except OSError as ex: - click.echo(ex) + logger.error(ex) continue self.targets.append(client) - def update(self): + def get_all_stats(self): for target in self.targets: - for response in target.stats(): - self.parse_metrics(*response) - - def setup_dhcp4_metrics(self): - self.metrics_dhcp4 = { - # Packets - "sent_packets": Gauge(f"{self.prefix_dhcp4}_packets_sent_total", "Packets sent", ["operation"]), - "received_packets": Gauge( - f"{self.prefix_dhcp4}_packets_received_total", - "Packets received", - ["operation"], - ), - # per Subnet or Subnet pool - "addresses_allocation_fail": Gauge( - f"{self.prefix_dhcp4}_allocations_failed_total", - "Allocation fail count", - [ - "subnet", - "subnet_id", - "context", - ], - ), - "addresses_assigned_total": Gauge( - f"{self.prefix_dhcp4}_addresses_assigned_total", - "Assigned addresses", - ["subnet", "subnet_id", "pool"], - ), - "addresses_declined_total": Gauge( - f"{self.prefix_dhcp4}_addresses_declined_total", - "Declined counts", - ["subnet", "subnet_id", "pool"], - ), - "addresses_declined_reclaimed_total": Gauge( - f"{self.prefix_dhcp4}_addresses_declined_reclaimed_total", - "Declined addresses that were reclaimed", - ["subnet", "subnet_id", "pool"], - ), - "addresses_reclaimed_total": Gauge( - f"{self.prefix_dhcp4}_addresses_reclaimed_total", - "Expired addresses that were reclaimed", - ["subnet", "subnet_id", "pool"], - ), - "addresses_total": Gauge( - f"{self.prefix_dhcp4}_addresses_total", - "Size of subnet address pool", - ["subnet", "subnet_id", "pool"], - ), - "reservation_conflicts_total": Gauge( - f"{self.prefix_dhcp4}_reservation_conflicts_total", - "Reservation conflict count", - ["subnet", "subnet_id"], - ), - "leases_reused_total": Gauge( - f"{self.prefix_dhcp4}_leases_reused_total", - "Number of times an IPv4 lease has been renewed in memory", - ["subnet", "subnet_id"], - ), - } - - self.metrics_dhcp4_map = { - # sent_packets - "pkt4-ack-sent": { - "metric": "sent_packets", - "labels": {"operation": "ack"}, - }, - "pkt4-nak-sent": { - "metric": "sent_packets", - "labels": {"operation": "nak"}, - }, - "pkt4-offer-sent": { - "metric": "sent_packets", - "labels": {"operation": "offer"}, - }, - # received_packets - "pkt4-discover-received": { - "metric": "received_packets", - "labels": {"operation": "discover"}, - }, - "pkt4-offer-received": { - "metric": "received_packets", - "labels": {"operation": "offer"}, - }, - "pkt4-request-received": { - "metric": "received_packets", - "labels": {"operation": "request"}, - }, - "pkt4-ack-received": { - "metric": "received_packets", - "labels": {"operation": "ack"}, - }, - "pkt4-nak-received": { - "metric": "received_packets", - "labels": {"operation": "nak"}, - }, - "pkt4-release-received": { - "metric": "received_packets", - "labels": {"operation": "release"}, - }, - "pkt4-decline-received": { - "metric": "received_packets", - "labels": {"operation": "decline"}, - }, - "pkt4-inform-received": { - "metric": "received_packets", - "labels": {"operation": "inform"}, - }, - "pkt4-unknown-received": { - "metric": "received_packets", - "labels": {"operation": "unknown"}, - }, - "pkt4-parse-failed": { - "metric": "received_packets", - "labels": {"operation": "parse-failed"}, - }, - "pkt4-receive-drop": { - "metric": "received_packets", - "labels": {"operation": "drop"}, - }, - # per Subnet or pool - "v4-allocation-fail-subnet": { - "metric": "addresses_allocation_fail", - "labels": {"context": "subnet"}, - }, - "v4-allocation-fail-shared-network": { - "metric": "addresses_allocation_fail", - "labels": {"context": "shared-network"}, - }, - "v4-allocation-fail-no-pools": { - "metric": "addresses_allocation_fail", - "labels": {"context": "no-pools"}, - }, - "v4-allocation-fail-classes": { - "metric": "addresses_allocation_fail", - "labels": {"context": "classes"}, - }, - "v4-lease-reuses": { - "metric": "leases_reused_total", - }, - "assigned-addresses": { - "metric": "addresses_assigned_total", - }, - "declined-addresses": { - "metric": "addresses_declined_total", - }, - "reclaimed-declined-addresses": { - "metric": "addresses_declined_reclaimed_total", - }, - "reclaimed-leases": { - "metric": "addresses_reclaimed_total", - }, - "total-addresses": { - "metric": "addresses_total", - }, - "v4-reservation-conflicts": { - "metric": "reservation_conflicts_total", - }, - } - # Ignore list for Global level metrics - self.metrics_dhcp4_global_ignore = [ - # metrics that exist at the subnet level in more detail - "cumulative-assigned-addresses", - "declined-addresses", - # sums of different packet types - "reclaimed-declined-addresses", - "reclaimed-leases", - "v4-reservation-conflicts", - "v4-allocation-fail", - "v4-allocation-fail-subnet", - "v4-allocation-fail-shared-network", - "v4-allocation-fail-no-pools", - "v4-allocation-fail-classes", - "pkt4-sent", - "pkt4-received", - "v4-lease-reuses", - ] - # Ignore list for subnet level metrics - self.metric_dhcp4_subnet_ignore = [ - "cumulative-assigned-addresses", - "v4-allocation-fail", - ] - - def setup_dhcp6_metrics(self): - self.metrics_dhcp6 = { - # Packets sent/received - "sent_packets": Gauge(f"{self.prefix_dhcp6}_packets_sent_total", "Packets sent", ["operation"]), - "received_packets": Gauge( - f"{self.prefix_dhcp6}_packets_received_total", - "Packets received", - ["operation"], - ), - # DHCPv4-over-DHCPv6 - "sent_dhcp4_packets": Gauge( - f"{self.prefix_dhcp6}_packets_sent_dhcp4_total", - "DHCPv4-over-DHCPv6 Packets received", - ["operation"], - ), - "received_dhcp4_packets": Gauge( - f"{self.prefix_dhcp6}_packets_received_dhcp4_total", - "DHCPv4-over-DHCPv6 Packets received", - ["operation"], - ), - # per Subnet or pool - "addresses_allocation_fail": Gauge( - f"{self.prefix_dhcp6}_allocations_failed_total", - "Allocation fail count", - [ - "subnet", - "subnet_id", - "context", - ], - ), - "addresses_declined_total": Gauge( - f"{self.prefix_dhcp6}_addresses_declined_total", - "Declined addresses", - ["subnet", "subnet_id", "pool"], - ), - "addresses_declined_reclaimed_total": Gauge( - f"{self.prefix_dhcp6}_addresses_declined_reclaimed_total", - "Declined addresses that were reclaimed", - ["subnet", "subnet_id", "pool"], - ), - "addresses_reclaimed_total": Gauge( - f"{self.prefix_dhcp6}_addresses_reclaimed_total", - "Expired addresses that were reclaimed", - ["subnet", "subnet_id", "pool"], - ), - "reservation_conflicts_total": Gauge( - f"{self.prefix_dhcp6}_reservation_conflicts_total", - "Reservation conflict count", - ["subnet", "subnet_id"], - ), - # IA_NA - "na_assigned_total": Gauge( - f"{self.prefix_dhcp6}_na_assigned_total", - "Assigned non-temporary addresses (IA_NA)", - ["subnet", "subnet_id", "pool"], - ), - "na_total": Gauge( - f"{self.prefix_dhcp6}_na_total", - "Size of non-temporary address pool", - ["subnet", "subnet_id", "pool"], - ), - "na_reuses_total": Gauge( - f"{self.prefix_dhcp6}_na_reuses_total", "Number of IA_NA lease reuses", ["subnet", "subnet_id", "pool"] - ), - # IA_PD - "pd_assigned_total": Gauge( - f"{self.prefix_dhcp6}_pd_assigned_total", - "Assigned prefix delegations (IA_PD)", - ["subnet", "subnet_id"], - ), - "pd_total": Gauge( - f"{self.prefix_dhcp6}_pd_total", - "Size of prefix delegation pool", - ["subnet", "subnet_id"], - ), - "pd_reuses_total": Gauge( - f"{self.prefix_dhcp6}_pd_reuses_total", "Number of IA_PD lease reuses", ["subnet", "subnet_id", "pool"] - ), - } - - self.metrics_dhcp6_map = { - # sent_packets - "pkt6-advertise-sent": { - "metric": "sent_packets", - "labels": {"operation": "advertise"}, - }, - "pkt6-reply-sent": { - "metric": "sent_packets", - "labels": {"operation": "reply"}, - }, - # received_packets - "pkt6-receive-drop": { - "metric": "received_packets", - "labels": {"operation": "drop"}, - }, - "pkt6-parse-failed": { - "metric": "received_packets", - "labels": {"operation": "parse-failed"}, - }, - "pkt6-solicit-received": { - "metric": "received_packets", - "labels": {"operation": "solicit"}, - }, - "pkt6-advertise-received": { - "metric": "received_packets", - "labels": {"operation": "advertise"}, - }, - "pkt6-request-received": { - "metric": "received_packets", - "labels": {"operation": "request"}, - }, - "pkt6-reply-received": { - "metric": "received_packets", - "labels": {"operation": "reply"}, - }, - "pkt6-renew-received": { - "metric": "received_packets", - "labels": {"operation": "renew"}, - }, - "pkt6-rebind-received": { - "metric": "received_packets", - "labels": {"operation": "rebind"}, - }, - "pkt6-release-received": { - "metric": "received_packets", - "labels": {"operation": "release"}, - }, - "pkt6-decline-received": { - "metric": "received_packets", - "labels": {"operation": "decline"}, - }, - "pkt6-infrequest-received": { - "metric": "received_packets", - "labels": {"operation": "infrequest"}, - }, - "pkt6-unknown-received": { - "metric": "received_packets", - "labels": {"operation": "unknown"}, - }, - # DHCPv4-over-DHCPv6 - "pkt6-dhcpv4-response-sent": { - "metric": "sent_dhcp4_packets", - "labels": {"operation": "response"}, - }, - "pkt6-dhcpv4-query-received": { - "metric": "received_dhcp4_packets", - "labels": {"operation": "query"}, - }, - "pkt6-dhcpv4-response-received": { - "metric": "received_dhcp4_packets", - "labels": {"operation": "response"}, - }, - # per Subnet - "v6-allocation-fail-shared-network": { - "metric": "addresses_allocation_fail", - "labels": {"context": "shared-network"}, - }, - "v6-allocation-fail-subnet": { - "metric": "addresses_allocation_fail", - "labels": {"context": "subnet"}, - }, - "v6-allocation-fail-no-pools": { - "metric": "addresses_allocation_fail", - "labels": {"context": "no-pools"}, - }, - "v6-allocation-fail-classes": { - "metric": "addresses_allocation_fail", - "labels": {"context": "classes"}, - }, - "assigned-nas": { - "metric": "na_assigned_total", - }, - "assigned-pds": { - "metric": "pd_assigned_total", - }, - "declined-addresses": { - "metric": "addresses_declined_total", - }, - "declined-reclaimed-addresses": { - "metric": "addresses_declined_reclaimed_total", - }, - "reclaimed-declined-addresses": { - "metric": "addresses_declined_reclaimed_total", - }, - "reclaimed-leases": { - "metric": "addresses_reclaimed_total", - }, - "total-nas": { - "metric": "na_total", - }, - "total-pds": { - "metric": "pd_total", - }, - "v6-reservation-conflicts": { - "metric": "reservation_conflicts_total", - }, - "v6-ia-na-lease-reuses": {"metric": "na_reuses_total"}, - "v6-ia-pd-lease-reuses": {"metric": "pd_reuses_total"}, - } - - # Ignore list for Global level metrics - self.metrics_dhcp6_global_ignore = [ - # metrics that exist at the subnet level in more detail - "cumulative-assigned-addresses", - "declined-addresses", - # sums of different packet types - "cumulative-assigned-nas", - "cumulative-assigned-pds", - "reclaimed-declined-addresses", - "reclaimed-leases", - "v6-reservation-conflicts", - "v6-allocation-fail", - "v6-allocation-fail-subnet", - "v6-allocation-fail-shared-network", - "v6-allocation-fail-no-pools", - "v6-allocation-fail-classes", - "v6-ia-na-lease-reuses", - "v6-ia-pd-lease-reuses", - "pkt6-sent", - "pkt6-received", - ] - # Ignore list for subnet level metrics - self.metric_dhcp6_subnet_ignore = [ - "cumulative-assigned-addresses", - "cumulative-assigned-nas", - "cumulative-assigned-pds", - "v6-allocation-fail", - ] - - def parse_metrics(self, dhcp_version, arguments, subnets): - for key, data in arguments.items(): - if dhcp_version is DHCPVersion.DHCP4: - if key in self.metrics_dhcp4_global_ignore: - continue - elif dhcp_version is DHCPVersion.DHCP6: - if key in self.metrics_dhcp6_global_ignore: - continue - else: - continue - - value, _ = data[0] - labels = {} - - subnet_match = self.subnet_pattern.match(key) - if subnet_match: - subnet_id = int(subnet_match.group("subnet_id")) - pool_index = subnet_match.group("pool_index") - pool_metric = subnet_match.group("pool_metric") - subnet_metric = subnet_match.group("subnet_metric") - - if dhcp_version is DHCPVersion.DHCP4: - if ( - pool_metric in self.metric_dhcp4_subnet_ignore - or subnet_metric in self.metric_dhcp4_subnet_ignore - ): - continue - elif dhcp_version is DHCPVersion.DHCP6: - if ( - pool_metric in self.metric_dhcp6_subnet_ignore - or subnet_metric in self.metric_dhcp6_subnet_ignore - ): - continue - else: - continue - - subnet_data = subnets.get(subnet_id, []) - if not subnet_data: - if subnet_id not in self.subnet_missing_info_sent.get(dhcp_version, []): - self.subnet_missing_info_sent.get(dhcp_version, []).append(subnet_id) - click.echo( - "Ignoring metric because subnet vanished from configuration: " - f"{dhcp_version.name=}, {subnet_id=}", - file=sys.stderr, - ) - continue - - labels["subnet"] = subnet_data.get("subnet") - labels["subnet_id"] = subnet_id - - # Check if subnet matches the pool_index - if pool_index: - # Matched for subnet pool metrics - pool_index = int(pool_index) - subnet_pools = [pool.get("pool") for pool in subnet_data.get("pools", [])] - - if len(subnet_pools) <= pool_index: - if f"{subnet_id}-{pool_index}" not in self.subnet_missing_info_sent.get(dhcp_version, []): - self.subnet_missing_info_sent.get(dhcp_version, []).append(f"{subnet_id}-{pool_index}") - click.echo( - "Ignoring metric because subnet vanished from configuration: " - f"{dhcp_version.name=}, {subnet_id=}, {pool_index=}", - file=sys.stderr, - ) - continue - key = pool_metric - labels["pool"] = subnet_pools[pool_index] - else: - # Matched for subnet metrics - key = subnet_metric - labels["pool"] = "" - - if dhcp_version is DHCPVersion.DHCP4: - metrics_map = self.metrics_dhcp4_map - metrics = self.metrics_dhcp4 - elif dhcp_version is DHCPVersion.DHCP6: - metrics_map = self.metrics_dhcp6_map - metrics = self.metrics_dhcp6 - else: - continue - - try: - metric_info = metrics_map[key] - except KeyError: - if key not in self.unhandled_metrics: - click.echo( - f"Unhandled metric '{key}' please file an issue at https://github.com/mweinelt/kea-exporter" - ) - self.unhandled_metrics.add(key) - continue - - metric = metrics[metric_info["metric"]] - - # merge static and dynamic labels - labels.update(metric_info.get("labels", {})) - - # Filter labels that are not configured for the metric - labels = {key: val for key, val in labels.items() if key in metric._labelnames} - - # export labels and value - metric.labels(**labels).set(value) + yield from target.stats() diff --git a/kea_exporter/http.py b/kea_exporter/http.py index 4978b38..95d3775 100644 --- a/kea_exporter/http.py +++ b/kea_exporter/http.py @@ -19,9 +19,10 @@ def __init__(self, target, client_cert, client_key, **kwargs): self.modules = [] self.subnets = {} self.subnets6 = {} + self.server_tag = "" self.load_modules() - self.load_subnets() + self.load_config() def load_modules(self): r = requests.post( @@ -35,7 +36,7 @@ def load_modules(self): if "dhcp" in module: # Does not support d2 metrics. # Does not handle ctrl sockets that are offline self.modules.append(module) - def load_subnets(self): + def load_config(self): r = requests.post( self._target, cert=self._cert, @@ -43,15 +44,21 @@ def load_subnets(self): headers={"Content-Type": "application/json"}, ) config = r.json() + for module in config: - for subnet in module.get("arguments", {}).get("Dhcp4", {}).get("subnet4", {}): - self.subnets.update({subnet["id"]: subnet}) - for subnet in module.get("arguments", {}).get("Dhcp6", {}).get("subnet6", {}): - self.subnets6.update({subnet["id"]: subnet}) + dhcp4_config = module.get("arguments", {}).get("Dhcp4", None) + + if dhcp4_config: + self.subnets = dhcp4_config.get("subnet4") + self.server_tag = dhcp4_config.get("server-tag", "") + dhcp6_config = module.get("arguments", {}).get("Dhcp6", None) + if dhcp6_config: + self.subnets6 = dhcp6_config.get("subnet6", {}) + self.server_tag = dhcp6_config.get("server-tag", "") def stats(self): - # Reload subnets on update in case of configurational update - self.load_subnets() + # Reload config on update in case of a configurational update + self.load_config() # Note for future testing: pipe curl output to jq for an easier read r = requests.post( self._target, @@ -77,4 +84,4 @@ def stats(self): arguments = response[index].get("arguments", {}) - yield dhcp_version, arguments, subnets + yield self._target, self.server_tag, dhcp_version, arguments, subnets diff --git a/kea_exporter/metrics.py b/kea_exporter/metrics.py new file mode 100644 index 0000000..f1ca45f --- /dev/null +++ b/kea_exporter/metrics.py @@ -0,0 +1,331 @@ +from __future__ import annotations + +import logging + +from prometheus_client.core import ( + GaugeMetricFamily, +) + +from kea_exporter import DHCPVersion + +logger = logging.getLogger(__name__) + +BASE_LABELS = ["target", "server_tag"] + +OPERATION_LABEL = ["operation"] + +SUBNET_LABELS = ["subnet", "subnet_id"] + +POOL_LABELS = ["pool"] + + +def labels_dict_to_list(metric, labels: dict) -> list: + return_labels = [] + for label in metric._labelnames: + return_labels.append(labels.get(label, "")) + + return return_labels + + +def get_server_metrics(): + return [ + GaugeMetricFamily("kea_dhcp4_packets_sent_total", "Packets sent", labels=[*BASE_LABELS, *OPERATION_LABEL]), + GaugeMetricFamily( + "kea_dhcp4_packets_received_total", "Packets received", labels=[*BASE_LABELS, *OPERATION_LABEL] + ), + GaugeMetricFamily("kea_dhcp6_packets_sent_total", "Packets sent", labels=[*BASE_LABELS, *OPERATION_LABEL]), + GaugeMetricFamily( + "kea_dhcp6_packets_received_total", "Packets received", labels=[*BASE_LABELS, *OPERATION_LABEL] + ), + GaugeMetricFamily( + "kea_dhcp6_packets_sent_dhcp4_total", + "DHCPv4-over-DHCPv6 Packets received", + labels=[*BASE_LABELS, *OPERATION_LABEL], + ), + GaugeMetricFamily( + "kea_dhcp6_packets_received_dhcp4_total", + "DHCPv4-over-DHCPv6 Packets received", + labels=[*BASE_LABELS, *OPERATION_LABEL], + ), + ] + + +def get_subnet_metrics(): + return [ + GaugeMetricFamily( + "kea_dhcp4_allocations_failed_total", "Allocation fail count", labels=[*BASE_LABELS, *SUBNET_LABELS] + ), + GaugeMetricFamily( + "kea_dhcp4_leases_reused_total", + "Number of times an IPv4 lease has been renewed in memory", + labels=[*BASE_LABELS, *SUBNET_LABELS], + ), + GaugeMetricFamily( + "kea_dhcp4_addresses_assigned_total", + "Assigned addresses", + labels=[*BASE_LABELS, *SUBNET_LABELS, *POOL_LABELS], + ), + GaugeMetricFamily( + "kea_dhcp4_addresses_declined_total", "Declined counts", labels=[*BASE_LABELS, *SUBNET_LABELS, *POOL_LABELS] + ), + GaugeMetricFamily( + "kea_dhcp4_addresses_declined_reclaimed_total", + "Declined addresses that were reclaimed", + labels=[*BASE_LABELS, *SUBNET_LABELS, *POOL_LABELS], + ), + GaugeMetricFamily( + "kea_dhcp4_addresses_reclaimed_total", + "Expired addresses that were reclaimed", + labels=[*BASE_LABELS, *SUBNET_LABELS, *POOL_LABELS], + ), + GaugeMetricFamily( + "kea_dhcp4_addresses_total", + "Size of subnet address pool", + labels=[*BASE_LABELS, *SUBNET_LABELS, *POOL_LABELS], + ), + GaugeMetricFamily( + "kea_dhcp4_reservation_conflicts_total", "Reservation conflict count", labels=[*BASE_LABELS, *SUBNET_LABELS] + ), + GaugeMetricFamily( + "kea_dhcp6_allocations_failed_total", "Allocation fail count", labels=[*BASE_LABELS, *SUBNET_LABELS] + ), + GaugeMetricFamily( + "kea_dhcp6_na_assigned_total", + "Assigned non-temporary addresses (IA_NA)", + labels=[*BASE_LABELS, *SUBNET_LABELS, *POOL_LABELS], + ), + GaugeMetricFamily( + "kea_dhcp6_pd_assigned_total", "Assigned prefix delegations (IA_PD)", labels=[*BASE_LABELS, *SUBNET_LABELS] + ), + GaugeMetricFamily( + "kea_dhcp6_addresses_declined_total", + "Declined addresses", + labels=[*BASE_LABELS, *SUBNET_LABELS, *POOL_LABELS], + ), + GaugeMetricFamily( + "kea_dhcp6_addresses_declined_reclaimed_total", + "Declined addresses that were reclaimed", + labels=[*BASE_LABELS, *SUBNET_LABELS, *POOL_LABELS], + ), + GaugeMetricFamily( + "kea_dhcp6_addresses_reclaimed_total", + "Expired addresses that were reclaimed", + labels=[*BASE_LABELS, *SUBNET_LABELS, *POOL_LABELS], + ), + GaugeMetricFamily( + "kea_dhcp6_na_total", + "Size of non-temporary address pool", + labels=[*BASE_LABELS, *SUBNET_LABELS, *POOL_LABELS], + ), + GaugeMetricFamily( + "kea_dhcp6_pd_total", "Size of prefix delegation pool", labels=[*BASE_LABELS, *SUBNET_LABELS] + ), + GaugeMetricFamily( + "kea_dhcp6_reservation_conflicts_total", "Reservation conflict count", labels=[*BASE_LABELS, *SUBNET_LABELS] + ), + GaugeMetricFamily( + "kea_dhcp6_na_reuses_total", + "Number of IA_NA lease reuses", + labels=[*BASE_LABELS, *SUBNET_LABELS, *POOL_LABELS], + ), + GaugeMetricFamily( + "kea_dhcp6_pd_reuses_total", + "Number of IA_PD lease reuses", + labels=[*BASE_LABELS, *SUBNET_LABELS, *POOL_LABELS], + ), + ] + + +metric_map = { + # IPv4 + "kea_dhcp4_packets_sent_total": [ + {"statistic": "pkt4-ack-sent", "labels": {"operation": "ack"}}, + {"statistic": "pkt4-nak-sent", "labels": {"operation": "nak"}}, + {"statistic": "pkt4-offer-sent", "labels": {"operation": "offer"}}, + ], + "kea_dhcp4_packets_received_total": [ + {"statistic": "pkt4-discover-received", "labels": {"operation": "discover"}}, + {"statistic": "pkt4-offer-received", "labels": {"operation": "offer"}}, + {"statistic": "pkt4-request-received", "labels": {"operation": "request"}}, + {"statistic": "pkt4-ack-received", "labels": {"operation": "ack"}}, + {"statistic": "pkt4-nak-received", "labels": {"operation": "nak"}}, + {"statistic": "pkt4-release-received", "labels": {"operation": "release"}}, + {"statistic": "pkt4-decline-received", "labels": {"operation": "decline"}}, + {"statistic": "pkt4-inform-received", "labels": {"operation": "inform"}}, + {"statistic": "pkt4-unknown-received", "labels": {"operation": "unknown"}}, + {"statistic": "pkt4-parse-failed", "labels": {"operation": "parse-failed"}}, + {"statistic": "pkt4-receive-drop", "labels": {"operation": "drop"}}, + ], + "kea_dhcp4_allocations_failed_total": [ + {"statistic": "v4-allocation-fail-subnet", "labels": {"context": "subnet"}}, + {"statistic": "v4-allocation-fail-shared-network", "labels": {"context": "shared-network"}}, + {"statistic": "v4-allocation-fail-no-pools", "labels": {"context": "no-pools"}}, + {"statistic": "v4-allocation-fail-classes", "labels": {"context": "classes"}}, + ], + "kea_dhcp4_leases_reused_total": [{"statistic": "v4-lease-reuses", "labels": {}}], + "kea_dhcp4_addresses_assigned_total": [{"statistic": "assigned-addresses", "labels": {}}], + "kea_dhcp4_addresses_declined_total": [{"statistic": "declined-addresses", "labels": {}}], + "kea_dhcp4_addresses_declined_reclaimed_total": [{"statistic": "reclaimed-declined-addresses", "labels": {}}], + "kea_dhcp4_addresses_reclaimed_total": [{"statistic": "reclaimed-leases", "labels": {}}], + "kea_dhcp4_addresses_total": [{"statistic": "total-addresses", "labels": {}}], + "kea_dhcp4_reservation_conflicts_total": [{"statistic": "v4-reservation-conflicts", "labels": {}}], + # IPv6 + "kea_dhcp6_packets_sent_total": [ + {"statistic": "pkt6-advertise-sent", "labels": {"operation": "advertise"}}, + {"statistic": "pkt6-reply-sent", "labels": {"operation": "reply"}}, + ], + "kea_dhcp6_packets_received_total": [ + {"statistic": "pkt6-receive-drop", "labels": {"operation": "drop"}}, + {"statistic": "pkt6-parse-failed", "labels": {"operation": "parse-failed"}}, + {"statistic": "pkt6-solicit-received", "labels": {"operation": "solicit"}}, + {"statistic": "pkt6-advertise-received", "labels": {"operation": "advertise"}}, + {"statistic": "pkt6-request-received", "labels": {"operation": "request"}}, + {"statistic": "pkt6-reply-received", "labels": {"operation": "reply"}}, + {"statistic": "pkt6-renew-received", "labels": {"operation": "renew"}}, + {"statistic": "pkt6-rebind-received", "labels": {"operation": "rebind"}}, + {"statistic": "pkt6-release-received", "labels": {"operation": "release"}}, + {"statistic": "pkt6-decline-received", "labels": {"operation": "decline"}}, + {"statistic": "pkt6-infrequest-received", "labels": {"operation": "infrequest"}}, + {"statistic": "pkt6-unknown-received", "labels": {"operation": "unknown"}}, + ], + "kea_dhcp6_packets_sent_dhcp4_total": [ + {"statistic": "pkt6-dhcpv4-response-sent", "labels": {"operation": "response"}} + ], + "kea_dhcp6_packets_received_dhcp4_total": [ + {"statistic": "pkt6-dhcpv4-query-received", "labels": {"operation": "query"}}, + {"statistic": "pkt6-dhcpv4-response-received", "labels": {"operation": "response"}}, + ], + "kea_dhcp6_allocations_failed_total": [ + {"statistic": "v6-allocation-fail-shared-network", "labels": {"context": "shared-network"}}, + {"statistic": "v6-allocation-fail-subnet", "labels": {"context": "subnet"}}, + {"statistic": "v6-allocation-fail-no-pools", "labels": {"context": "no-pools"}}, + {"statistic": "v6-allocation-fail-classes", "labels": {"context": "classes"}}, + ], + "kea_dhcp6_na_assigned_total": [{"statistic": "assigned-nas", "labels": {}}], + "kea_dhcp6_pd_assigned_total": [{"statistic": "assigned-pds", "labels": {}}], + "kea_dhcp6_addresses_declined_total": [{"statistic": "declined-addresses", "labels": {}}], + "kea_dhcp6_addresses_declined_reclaimed_total": [ + {"statistic": "declined-reclaimed-addresses", "labels": {}}, + {"statistic": "reclaimed-declined-addresses", "labels": {}}, + ], + "kea_dhcp6_addresses_reclaimed_total": [{"statistic": "reclaimed-leases", "labels": {}}], + "kea_dhcp6_na_total": [{"statistic": "total-nas", "labels": {}}], + "kea_dhcp6_pd_total": [{"statistic": "total-pds", "labels": {}}], + "kea_dhcp6_reservation_conflicts_total": [{"statistic": "v6-reservation-conflicts", "labels": {}}], + "kea_dhcp6_na_reuses_total": [{"statistic": "v6-ia-na-lease-reuses", "labels": {}}], + "kea_dhcp6_pd_reuses_total": [{"statistic": "v6-ia-pd-lease-reuses", "labels": {}}], +} + + +class KeaMetrics: + def __init__(self, all_stats): + """ + :param all_stats: A dictionary where keys are labels and values are statistics for each source. + """ + self.all_stats = all_stats + + def collect_metrics(self): + """ + Create Prometheus metrics for all sources. + """ + + yield from self.collect_server_metrics() + + yield from self.collect_subnet_metrics() + + def collect_server_metrics(self): + # Generate all server related metrics + server_metrics = get_server_metrics() + for server_metric in server_metrics: + mapped_stats = metric_map.get(server_metric.name) + + for mapped_stat_dict in mapped_stats: + mapped_stat = mapped_stat_dict.get("statistic", "") + extra_labels = mapped_stat_dict.get("labels", {}) + + for target, server_tag, dhcp_version, arguments, _ in self.all_stats: + if dhcp_version == DHCPVersion.DHCP4 and "dhcp4" not in server_metric.name: + # Looping DHCP4 but looking at data for dhcp6 + continue + if dhcp_version == DHCPVersion.DHCP6 and "dhcp6" not in server_metric.name: + # Looping DHCP6 but looking at data for dhcp4 + continue + + try: + value = arguments.get(mapped_stat, [])[0][0] + except IndexError: + logging.debug(f"Did not find any value for metric: {server_metric.name}, stat: {mapped_stat}") + # Did not find any statistic regarding this pool, skipping + continue + + base_labels = {"target": target, "server_tag": server_tag} + labels = labels_dict_to_list(server_metric, {**base_labels, **extra_labels}) + server_metric.add_metric(value=value, labels=labels) + yield server_metric + + def collect_subnet_metrics(self): + # Generate all subnet related metrics + subnet_metrics = get_subnet_metrics() + + for subnet_metric in subnet_metrics: + mapped_stats = metric_map.get(subnet_metric.name) + logger.debug(subnet_metric.name) + for mapped_stat_dict in mapped_stats: + mapped_stat = mapped_stat_dict.get("statistic", "") + extra_labels = mapped_stat_dict.get("labels", {}) + + logger.debug(f"Mapped_stat: {mapped_stat}") + + for target, server_tag, dhcp_version, arguments, subnets in self.all_stats: + if dhcp_version == DHCPVersion.DHCP4 and "dhcp4" not in subnet_metric.name: + # Looping DHCP4 but looking at data for dhcp6 + continue + if dhcp_version == DHCPVersion.DHCP6 and "dhcp6" not in subnet_metric.name: + # Looping DHCP6 but looking at data for dhcp4 + continue + + base_labels = {"target": target, "server_tag": server_tag} + logging.debug(subnets) + for subnet in subnets: + subnet_id = subnet.get("id") + + subnet_prefix = subnet.get("subnet") + + logger.debug(f"Looping {subnet_prefix}") + try: + value = arguments.get(f"subnet[{subnet_id}].{mapped_stat}", [])[0][0] + logger.debug(f"Found value for {subnet_prefix} in {mapped_stat}") + except IndexError: + logger.debug(f"Did not find stat: {mapped_stat} for subnet: {subnet_prefix}") + # Did not find any statistic regarding this subnet, skipping + continue + subnet_labels = {"subnet": subnet_prefix, "subnet_id": str(subnet_id)} + + labels = labels_dict_to_list(subnet_metric, {**base_labels, **subnet_labels, **extra_labels}) + logger.debug(f"Adding {subnet_metric.name} for subnet: {subnet_prefix}") + subnet_metric.add_metric(value=value, labels=labels) + logger.debug(subnet_metric.samples) + + # Add metrics for pools in the subnet + for pool_index, pool in enumerate(subnet.get("pools")): + # logger.debug(f"Looping pool: {pool_index}, {pool}") + try: + value = arguments.get(f"subnet[{subnet_id}].pool[{pool_index}].{mapped_stat}", [])[0][0] + except IndexError: + logger.debug(f"Did not find stat: {mapped_stat} for pool: {pool}") + continue + pool_labels = { + "subnet": subnet_prefix, + "subnet_id": str(subnet_id), + "pool": pool.get("pool"), + } + + labels = labels_dict_to_list(subnet_metric, {**base_labels, **pool_labels, **extra_labels}) + subnet_metric.add_metric(value=value, labels=labels) + + if subnet_metric.samples: + logger.debug(f"yielding metric: {subnet_metric}") + yield subnet_metric + else: + logger.debug(f"Skipping yielding metric: {subnet_metric}") + pass diff --git a/kea_exporter/uds.py b/kea_exporter/uds.py index 703dc47..e51f88e 100644 --- a/kea_exporter/uds.py +++ b/kea_exporter/uds.py @@ -1,12 +1,13 @@ import json +import logging import os import socket import sys -import click - from kea_exporter import DHCPVersion +logger = logging.getLogger(__name__) + class KeaSocketClient: def __init__(self, sock_path, **kwargs): @@ -19,10 +20,8 @@ def __init__(self, sock_path, **kwargs): self.sock_path = os.path.abspath(sock_path) - self.version = None - self.config = None self.subnets = None - self.subnet_missing_info_sent = [] + self.server_tag = "" self.dhcp_version = None def query(self, command): @@ -43,23 +42,22 @@ def stats(self): arguments = self.query("statistic-get-all").get("arguments", {}) - yield self.dhcp_version, arguments, self.subnets + yield self.sock_path, self.server_tag, self.dhcp_version, arguments, self.subnets def reload(self): - self.config = self.query("config-get")["arguments"] + config = self.query("config-get").get("arguments", {}) - if "Dhcp4" in self.config: + if "Dhcp4" in config: self.dhcp_version = DHCPVersion.DHCP4 - subnets = self.config["Dhcp4"]["subnet4"] - elif "Dhcp6" in self.config: + subnets = config.get("Dhcp4", {}).get("subnet4", []) + self.server_tag = config.get("Dhcp4", {}).get("server-tag", "") + elif "Dhcp6" in config: self.dhcp_version = DHCPVersion.DHCP6 - subnets = self.config["Dhcp6"]["subnet6"] + subnets = config.get("Dhcp6", {}).get("subnet6", []) + self.server_tag = config.get("Dhcp6", {}).get("server-tag", "") else: - click.echo( + logging.error( f"Socket {self.sock_path} has no supported configuration", - file=sys.stderr, ) sys.exit(1) - - # create subnet map - self.subnets = {subnet["id"]: subnet for subnet in subnets} + self.subnets = subnets