# Copyright 2021 Canonical Ltd.
# See LICENSE file for licensing details.
"""## Overview.

This document explains how to integrate with the Prometheus charm
for the purposes of providing a metrics endpoint to Prometheus. It
also explains how alternative implementations of the Prometheus charm
may maintain the same interface and be backward compatible with all
currently integrated charms. Finally this document is the
authoritative reference on the structure of relation data that is
shared between Prometheus charms and any other charm that intends to
provide a scrape target for Prometheus.

## Provider Library Usage

This Prometheus charm interacts with its scrape targets using its
charm library. Charms seeking to expose a metric endpoints for the
Prometheus charm, must do so using the `MetricsEndpointProvider`
object from this charm library. For the simplest use cases, using the
`MetricsEndpointProvider` object only requires instantiating it,
typically in the constructor of your charm (the one which exposes a
metrics endpoint). The `MetricsEndpointProvider` constructor requires
the name of the relation over which a scrape target (metrics endpoint)
is exposed to the Prometheus charm. This relation must use the
`prometheus_scrape` interface. The address of the metrics endpoint is
set to the unit address, by each unit of the `MetricsEndpointProvider`
charm. These units set their address in response to a specific
`CharmEvent`. Hence instantiating the `MetricsEndpointProvider` also
requires a `CharmEvent` object in response to which each unit will
post its address into the unit's relation data for the Prometheus
charm. Since container restarts of Kubernetes charms can result in
change of IP addresses, this event is typically `PebbleReady`. For
example, assuming your charm exposes a metrics endpoint over a
relation named "metrics_endpoint", you may instantiate
`MetricsEndpointProvider` as follows

    from charms.prometheus_k8s.v0.prometheus_scrape import MetricsEndpointProvider

    def __init__(self, *args):
        super().__init__(*args)
        ...
        self.metrics_endpoint = MetricsEndpointProvider(self, "metrics-endpoint",
                                                self.on.container_name_pebble_ready)
        ...

In this example `container_name_pebble_ready` is the `PebbleReady` event
in response to which each unit will advertise its address. Also note
that the first argument (`self`) to `MetricsEndpointProvider` is always a
reference to the parent (scrape target) charm.

An instantiated `MetricsEndpointProvider` object will ensure that each unit of
its parent charm, is a scrape target for the `MetricsEndpointConsumer`
(Prometheus). By default `MetricsEndpointProvider` assumes each unit of the
consumer charm exports its metrics at a path given by `/metrics` on
port 80. The defaults may be changed by providing the
`MetricsEndpointProvider` constructor an optional argument (`jobs`) that
represents list of Prometheus scrape job specification using Python
standard data structures. This job specification is a subset of
Prometheus' own [scrape
configuration](https://prometheus.io/docs/prometheus/latest/configuration/configuration/#scrape_config)
format but represented using Python data structures. More than one job
may be provided using the `jobs` argument. Hence `jobs` accepts a list
of dictionaries where each dictionary represents one `<scrape_config>`
object as described in the Prometheus documentation. The current
supported configuration subset is: `job_name`, `metrics_path`,
`static_configs`

Suppose it is required to change the port on which scraped metrics are
exposed to 8000. This may be done by providing the following data
structure as the value of `jobs`.

```
[
    {
        "static_configs": [
            {
                "targets": ["*:8000"]
            }
        ]
    }
]
```

The wildcard ("*") host specification implies that the scrape targets
will automatically be set to the host addresses advertised by each
unit of the consumer charm.

It is also possible to change the metrics path and scrape multiple
ports, for example

```
[
    {
        "metrics_path": "/my-metrics-path",
        "static_configs": [
            {
                "targets": ["*:8000", "*:8081"],
            }
        ]
    }
]
```

More complex scrape configurations are possible. For example

```
[
    {
        "static_configs": [
            {
                "targets": ["10.1.32.215:7000", "*:8000"],
                "labels": {
                    "some-key": "some-value"
                }
            }
        ]
    }
]
```

This example scrapes the target "10.1.32.215" at port 7000 in addition
to scraping each unit at port 8000. There is however one difference
between wildcard targets (specified using "*") and fully qualified
targets (such as "10.1.32.215"). The Prometheus charm automatically
associates labels with metrics generated by each target. These labels
localise the source of metrics within the Juju topology by specifying
its "model name", "model UUID", "application name" and "unit
name". However unit name is associated only with wildcard targets but
not with fully qualified targets.

Multiple jobs with different metrics paths and labels are allowed, but
each job must be given a unique name. For example

```
[
    {
        "job_name": "my-first-job",
        "metrics_path": "one-path",
        "static_configs": [
            {
                "targets": ["*:7000"],
                "labels": {
                    "some-key": "some-value"
                }
            }
        ]
    },
    {
        "job_name": "my-second-job",
        "metrics_path": "another-path",
        "static_configs": [
            {
                "targets": ["*:8000"],
                "labels": {
                    "some-other-key": "some-other-value"
                }
            }
        ]
    }
]
```

It is also possible to configure other scrape related parameters using
these job specifications as described by the Prometheus
[documentation](https://prometheus.io/docs/prometheus/latest/configuration/configuration/#scrape_config).
The permissible subset of job specific scrape configuration parameters
supported in a `MetricsEndpointProvider` job specification are:

- `job_name`
- `metrics_path`
- `static_configs`
- `scrape_interval`
- `scrape_timeout`
- `proxy_url`
- `relabel_configs`
- `metrics_relabel_configs`
- `sample_limit`
- `label_limit`
- `label_name_length_limit`
- `label_value_lenght_limit`

## Consumer Library Usage

The `MetricsEndpointConsumer` object may be used by Prometheus
charms to manage relations with their scrape targets. For this
purposes a Prometheus charm needs to do two things

1. Instantiate the `MetricsEndpointConsumer` object providing it
with three two pieces of information

- A reference to the parent (Prometheus) charm.

- Name of the relation that the Prometheus charm uses to interact with
  scrape targets. This relation must confirm to the
  `prometheus_scrape` interface.

For example a Prometheus charm may instantiate the
`MetricsEndpointConsumer` in its constructor as follows

    from charms.prometheus_k8s.v0.prometheus_scrape import MetricsEndpointConsumer

    def __init__(self, *args):
        super().__init__(*args)
        ...
        self.metrics_consumer = MetricsEndpointConsumer(
                self, "metrics-endpoint"
            )
        ...

2. A Prometheus charm also needs to respond to the
`TargetsChangedEvent` event of the `MetricsEndpointConsumer` by adding itself as
and observer for these events, as in

    self.framework.observe(
        self.metrics_consumer.on.targets_changed,
        self._on_scrape_targets_changed,
    )

In responding to the `TargetsChangedEvent` event the Prometheus
charm must update the Prometheus configuration so that any new scrape
targets are added and/or old ones removed from the list of scraped
endpoints. For this purpose the `MetricsEndpointConsumer` object
exposes a `jobs()` method that returns a list of scrape jobs. Each
element of this list is the Prometheus scrape configuration for that
job. In order to update the Prometheus configuration, the Prometheus
charm needs to replace the current list of jobs with the list provided
by `jobs()` as follows

    def _on_scrape_targets_changed(self, event):
        ...
        scrape_jobs = self.metrics_consumer.jobs()
        for job in scrape_jobs:
            prometheus_scrape_config.append(job)
        ...

## Alerting Rules

This charm library also supports gathering alerting rules from all
related `MetricsEndpointProvider` charms and enabling corresponding alerts within the
Prometheus charm.  Alert rules are automatically gathered by `MetricsEndpointProvider`
charms when using this library, from a directory conventionally named
`prometheus_alert_rules`. This directory must reside at the top level
in the `src` folder of the consumer charm. Each file in this directory
is assumed to be a single alert rule in YAML format. The file name must
have the `.rule` extension. The format of this alert rule conforms to the
[Prometheus docs](https://prometheus.io/docs/prometheus/latest/configuration/alerting_rules/).
An example of the contents of one such file is shown below.

```
alert: HighRequestLatency
expr: job:request_latency_seconds:mean5m{my_key=my_value, %%juju_topology%%} > 0.5
for: 10m
labels:
  severity: Medium
  type: HighLatency
annotations:
  summary: High request latency for {{ $labels.instance }}.
```

It is **very important** to note the `%%juju_topology%%` filter in the
expression for the alert rule shown above. This filter is a stub that
is automatically replaced by the metrics provider charm's Juju
topology (application, model and its UUID). Such a topology filter is
essential to ensure that alert rules submitted by one provider charm
generates alerts only for that same charm.  The Prometheus charm may
be related to multiple metrics provider charms. Without this, filter
rules submitted by one provider charm will also result in
corresponding alerts for other provider charms. Hence every alert rule
expression must include such a topology filter stub.

Gathering alert rules and generating rule files within the Prometheus
charm is easily done using the `alerts()` method of
`MetricsEndpointConsumer`. Alerts generated by the Prometheus will
automatically include Juju topology labels in the alerts. These labels
indicate the source of the alert. The following lables are
automatically included with each alert

- `juju_model`
- `juju_model_uuid`
- `juju_application`

## Relation Data

The Prometheus charm uses both application and unit relation data to
obtain information regarding its scrape jobs, alert rules and scrape
targets. This relation data is in JSON format and it closely resembles
the YAML structure of Prometheus [scrape configuration]
(https://prometheus.io/docs/prometheus/latest/configuration/configuration/#scrape_config).

Units of consumer charm advertise their address over unit relation
data using the `prometheus_scrape_host` key. While the
`scrape_metadata`, `scrape_jobs` and `alert_rules` keys in application
relation data provide eponymous information.

"""

import json
import logging
from pathlib import Path

import yaml
from ops.framework import EventBase, EventSource, Object, ObjectEvents

# The unique Charmhub library identifier, never change it
LIBID = "bc84295fef5f4049878f07b131968ee2"

# Increment this major API version when introducing breaking changes
LIBAPI = 0

# Increment this PATCH version before using `charmcraft publish-lib` or reset
# to 0 if you are raising the major API version
LIBPATCH = 7


logger = logging.getLogger(__name__)


ALLOWED_KEYS = {
    "job_name",
    "metrics_path",
    "static_configs",
    "scrape_interval",
    "scrape_timeout",
    "proxy_url",
    "relabel_configs",
    "metrics_relabel_configs",
    "sample_limit",
    "label_limit",
    "label_name_length_limit",
    "label_value_lenght_limit",
}
DEFAULT_JOB = {
    "metrics_path": "/metrics",
    "static_configs": [{"targets": ["*:80"]}],
}


def _sanitize_scrape_configuration(job) -> dict:
    """Restrict permissible scrape configuration options.

    If job is empty then a default job is returned. The
    default job is

    ```
    {
        "metrics_path": "/metrics",
        "static_configs": [{"targets": ["*:80"]}],
    }
    ```

    Args:
        job: a dict containing a single Prometheus job
            specification.

    Returns:
        a dictionary containing a sanitized job specification.
    """
    sanitized_job = DEFAULT_JOB.copy()
    sanitized_job.update({key: value for key, value in job.items() if key in ALLOWED_KEYS})
    return sanitized_job


class TargetsChangedEvent(EventBase):
    """Event emitted when Prometheus scrape targets change."""

    def __init__(self, handle, relation_id):
        super().__init__(handle)
        self.relation_id = relation_id

    def snapshot(self):
        """Save scrape target relation information."""
        return {"relation_id": self.relation_id}

    def restore(self, snapshot):
        """Restore scrape target relation information."""
        self.relation_id = snapshot["relation_id"]


class MonitoringEvents(ObjectEvents):
    """Event descriptor for events raised by `MetricsEndpointConsumer`."""

    targets_changed = EventSource(TargetsChangedEvent)


class MetricsEndpointConsumer(Object):
    """A Prometheus based Monitoring service provider."""

    on = MonitoringEvents()

    def __init__(self, charm, name):
        """A Prometheus based Monitoring service provider.

        Args:
            charm: a `CharmBase` instance that manages this
                instance of the Prometheus service.
            name: string name of the relation over which scrape target
                information is gathered by the Prometheus charm.
        """
        super().__init__(charm, name)
        self._charm = charm
        self._relation_name = name
        events = self._charm.on[name]
        self.framework.observe(events.relation_changed, self._on_metrics_provider_relation_changed)
        self.framework.observe(
            events.relation_departed, self._on_metrics_provider_relation_departed
        )

    def _on_metrics_provider_relation_changed(self, event):
        """Handle changes in related consumers.

        Anytime there are changes in relations between Prometheus
        and metrics provider charms the Prometheus charm is informed,
        through a `TargetsChangedEvent` event. The Prometheus charm can
        then choose to update its scrape configuration.

        Args:
            event: a `CharmEvent` in response to which the Prometheus
                charm must update its scrape configuration.
        """
        rel_id = event.relation.id

        self.on.targets_changed.emit(relation_id=rel_id)

    def _on_metrics_provider_relation_departed(self, event):
        """Update job config when consumers depart.

        When a metrics provider departs the scrape configuration
        for that provider is removed from the list of scrape jobs and
        the Prometheus is informed through a `TargetsChangedEvent`
        event.

        Args:
            event: a `CharmEvent` that indicates a metrics provider
               unit has departed.
        """
        rel_id = event.relation.id
        self.on.targets_changed.emit(relation_id=rel_id)

    def jobs(self) -> list:
        """Fetch the list of scrape jobs.

        Returns:
            A list consisting of all the static scrape configurations
            for each related `MetricsEndpointProvider` that has specified
            its scrape targets.
        """
        scrape_jobs = []

        for relation in self._charm.model.relations[self._relation_name]:
            static_scrape_jobs = self._static_scrape_config(relation)
            if static_scrape_jobs:
                scrape_jobs.extend(static_scrape_jobs)

        return scrape_jobs

    def alerts(self) -> dict:
        """Fetch alerts for all relations.

        A Prometheus alert rules file consists of a list of "groups". Each
        group consists of a list of alerts (`rules`) that are sequentially
        executed. This method returns all the alert rules provided by each
        related metrics provider charm. These rules may be used to generate a
        separate alert rules file for each relation since the returned list
        of alert groups are indexed by relation ID. Also for each relation ID
        associated scrape metadata such as Juju model, UUID and application
        name are provided so the a unique name may be generated for the rules
        file. For each relation the structure of data returned is a dictionary
        with four keys

        - groups
        - model
        - model_uuid
        - application

        The value of the `groups` key is such that it may be used to generate
        a Prometheus alert rules file directly using `yaml.dump` but the
        `groups` key itself must be included as this is required by Prometheus,
        for example as in `yaml.dump({"groups": alerts["groups"]})`.

        Currently the `MetricsEndpointProvider` only accepts a list of rules and these
        rules are all placed into a single group, even though Prometheus itself
        allows for multiple groups within a single alert rules file.

        Returns:
            a dictionary mapping the name of an alert rule group to the group.
        """
        alerts = {}
        for relation in self._charm.model.relations[self._relation_name]:
            if not relation.units:
                continue

            alert_rules = json.loads(relation.data[relation.app].get("alert_rules", "{}"))
            if not alert_rules:
                continue

            try:
                for group in alert_rules["groups"]:
                    alerts[group["name"]] = group
            except KeyError as e:
                logger.error(
                    "Relation %s has invalid data : %s",
                    relation.id,
                    e,
                )

        return alerts

    def _static_scrape_config(self, relation) -> list:
        """Generate the static scrape configuration for a single relation.

        Args:
            relation: an `ops.model.Relation` object whose static
                scrape configuration is required.

        Returns:
            A list (possibly empty) of scrape jobs. Each job is a
            valid Prometheus scrape configuration for that job,
            represented as a Python dictionary.
        """
        if not relation.units:
            return []

        scrape_jobs = json.loads(relation.data[relation.app].get("scrape_jobs", "[]"))

        if not scrape_jobs:
            return []

        scrape_metadata = json.loads(relation.data[relation.app].get("scrape_metadata", "{}"))

        if not scrape_metadata:
            return scrape_jobs

        job_name_prefix = "juju_{}_{}_{}_prometheus_scrape".format(
            scrape_metadata["model"],
            scrape_metadata["model_uuid"][:7],
            scrape_metadata["application"],
        )

        hosts = self._relation_hosts(relation)

        labeled_job_configs = []
        for job in scrape_jobs:
            config = self._labeled_static_job_config(
                _sanitize_scrape_configuration(job),
                job_name_prefix,
                hosts,
                scrape_metadata,
            )
            labeled_job_configs.append(config)

        return labeled_job_configs

    def _relation_hosts(self, relation) -> dict:
        """Fetch host names and address of all consumer units for a single relation.

        Args:
            relation: An `ops.model.Relation` object for which the host name to
                address mapping is required.

        Returns:
            A dictionary that maps unit names to unit addresses for
            the specified relation.
        """
        return {
            unit.name: relation.data[unit].get("prometheus_scrape_host")
            for unit in relation.units
            if relation.data[unit].get("prometheus_scrape_host")
        }

    def _labeled_static_job_config(self, job, job_name_prefix, hosts, scrape_metadata) -> dict:
        """Construct labeled job configuration for a single job.

        Args:

            job: a dictionary representing the job configuration as obtained from
                `MetricsEndpointProvider` over relation data.
            job_name_prefix: a string that may either be used as the
                job name if none is provided or used as a prefix for
                the provided job name.
            hosts: a dictionary mapping host names to host address for
                all units of the relation for which this job configuration
                must be constructed.
            scrape_metadata: scrape configuration metadata obtained
                from `MetricsEndpointProvider` from the same relation for
                which this job configuration is being constructed.

        Returns:
            A dictionary representing a Prometheus job configuration
            for a single job.
        """
        name = job.get("job_name")
        job_name = f"{job_name_prefix}_{name}" if name else job_name_prefix

        labeled_job = job.copy()
        labeled_job["job_name"] = job_name

        static_configs = job.get("static_configs")
        labeled_job["static_configs"] = []

        instance_relabel_config = {
            "source_labels": ["juju_model", "juju_model_uuid", "juju_application"],
            "separator": "_",
            "target_label": "instance",
            "regex": "(.*)",
        }

        # label all static configs in the Prometheus job
        # labeling inserts Juju topology information and
        # sets a relable config for instance labels
        for static_config in static_configs:
            labels = static_config.get("labels", {}) if static_configs else {}
            all_targets = static_config.get("targets", [])

            # split all targets into those which will have unit labels
            # and those which will not
            ports = []
            unitless_targets = []
            for target in all_targets:
                host, port = target.split(":")
                if host.strip() == "*":
                    ports.append(port.strip())
                else:
                    unitless_targets.append(target)

            # label scrape targets that do not have unit lables
            if unitless_targets:
                unitless_config = self._labeled_unitless_config(
                    unitless_targets, labels, scrape_metadata
                )
                labeled_job["static_configs"].append(unitless_config)

            # label scrape targets that do have unit labels
            for host_name, host_address in hosts.items():
                static_config = self._labeled_unit_config(
                    host_name, host_address, ports, labels, scrape_metadata
                )
                labeled_job["static_configs"].append(static_config)
                if "juju_unit" not in instance_relabel_config["source_labels"]:
                    instance_relabel_config["source_labels"].append("juju_unit")

        # ensure topology relabeling of instance label is last in order of relabelings
        relabel_configs = job.get("relabel_configs", [])
        relabel_configs.append(instance_relabel_config)
        labeled_job["relabel_configs"] = relabel_configs

        return labeled_job

    def _set_juju_labels(self, labels, scrape_metadata) -> dict:
        """Create a copy of metric labels with Juju topology information.

        Args:
            labels: a dictionary containing Prometheus metric labels.
            scrape_metadata: scrape related metadata provied by
                `MetricsEndpointProvider`.

        Returns:
            a copy of the `labels` dictionary augmented with Juju
            topology information with the exception of unit name.
        """
        juju_labels = labels.copy()  # deep copy not needed
        juju_labels["juju_model"] = f"{scrape_metadata['model']}"
        juju_labels["juju_model_uuid"] = f"{scrape_metadata['model_uuid']}"
        juju_labels["juju_application"] = f"{format(scrape_metadata['application'])}"

        return juju_labels

    def _labeled_unitless_config(self, targets, labels, scrape_metadata) -> dict:
        """Static scrape configuration for fully qualified host addresses.

        Fully qualified hosts are those scrape targets for which the
        address are not automatically determined by
        `MetricsEndpointConsumer` but instead are specified by the
        `MetricsEndpointProvider`.

        Args:
            targets: a list of addresses of fully qualified hosts.
            labels: labels specified by `MetricsEndpointProvider` clients
                 which are associated with `targets`.
            scrape_metadata: scrape related metadata provied by `MetricsEndpointProvider`.

        Returns:
            A dictionary containing the static scrape configuration
            for a list of fully qualified hosts.
        """
        juju_labels = self._set_juju_labels(labels, scrape_metadata)
        unitless_config = {"targets": targets, "labels": juju_labels}
        return unitless_config

    def _labeled_unit_config(
        self, host_name, host_address, ports, labels, scrape_metadata
    ) -> dict:
        """Static scrape configuration for a wildcard host.

        Wildcard hosts are those scrape targets whose address is
        automatically determined by `MetricsEndpointConsumer`.

        Args:
            host_name: a string representing the unit name of the wildcard host.
            host_address: a string representing the address of the wildcard host.
            ports: list of ports on which this wildcard host exposes its metrics.
            labels: a dictionary of labels provided by
                `MetricsEndpointProvider` intended to be associated with
                this wildcard host.
            scrape_metadata: scrape related metadata provied by `MetricsEndpointProvider`.

        Returns:
            A dictionary containing the static scrape configuration
            for a single wildcard host.
        """
        juju_labels = self._set_juju_labels(labels, scrape_metadata)

        # '/' is not allowed in Prometheus label names. It technically works,
        # but complex queries silently fail
        juju_labels["juju_unit"] = f"{host_name.replace('/', '-')}"

        static_config = {"labels": juju_labels}

        if ports:
            targets = []
            for port in ports:
                targets.append(f"{host_address}:{port}")
            static_config["targets"] = targets
        else:
            static_config["targets"] = [host_address]

        return static_config


class MetricsEndpointProvider(Object):
    """Construct a metrics provider for a Prometheus charm."""

    def __init__(
        self,
        charm,
        name,
        service_event,
        jobs=[],
        alert_rules_path="src/prometheus_alert_rules",
    ):
        """Construct a metrics provider for a Prometheus charm.

        The `MetricsEndpointProvider` object provides scrape configurations
        to a Prometheus charm. A charm instantiating this object has
        metrics from each of its units scraped by the related Prometheus
        charms. The scraped metrics are automatically tagged by the
        Prometheus charms with Juju topology data via the
        `juju_model_name`, `juju_model_uuid`, `juju_application_name`
        and `juju_unit` labels.

        The `MetricsEndpointProvider` can be instantiated as follows:

            self.prometheus = MetricsEndpointProvider(self, "metrics-endpoint",
                                                 self.container_name_pebble_ready)

        In response to relation joined events this metrics provider object
        will set the following relation data required by the Prometheus charm.
        - `scrape_metadata`
        - `scrape_jobs`
        - `alert_rules`

        The `alert_rules` are read from `*.rule` files in the `src/prometheus_alert_rules`
        directory. If the syntax of these rules is invalid `MetricsEndpointProvider` logs
        an error and does not load the particular rule.

        Args:
            charm: a `CharmBase` object that manages this
                `MetricsEndpointProvider` object. Typically this is
                `self` in the instantiating class.
            name: a string name of the relation between `charm` and
                the Prometheus charmed service.
            service_event: a `CharmEvent` in response to which each charm unit
                must advertise its scrape endpoint host address.
            jobs: an optional list of dictionaries where each
                dictionary represents the Prometheus scrape
                configuration for a single job. When not provided, a
                default scrape configuration is provided for the
                `/metrics` endpoint pooling using port `80`.
            alert_rules_path: an optional path for the location of alert rules
                files.  Defaults to "src/prometheus_alert_rules" at the top level
                of the charm repository.
        """
        super().__init__(charm, name)

        self._charm = charm
        self._ALERT_RULES_PATH = alert_rules_path
        self._service_event = service_event
        self._relation_name = name
        # Sanitize job configurations to the supported subset of parameters
        self._jobs = [_sanitize_scrape_configuration(job) for job in jobs]

        events = self._charm.on[self._relation_name]
        self.framework.observe(events.relation_joined, self._set_scrape_job_spec)
        self.framework.observe(events.relation_changed, self._set_scrape_job_spec)
        self.framework.observe(self._service_event, self._set_unit_ip)
        self.framework.observe(self._charm.on.upgrade_charm, self._set_scrape_job_spec)

    def _set_scrape_job_spec(self, event):
        """Ensure scrape target information is made available to Prometheus.

        When a metrics provider charm is related to a Prometheus charm, the
        metrics provider sets metadata related to its own scrape
        configutation.  This metadata is set using Juju application
        data.  In addition each of the consumer units also sets its own
        host address in Juju unit relation data.

        Args:
            event: a `CharmEvent` in response to which `MetricsEndpointProvider` will
                forward scrape jobs, alert rules, metrics endpoint host addresses
                and related metadata to the Prometheus charm.
        """
        self._set_unit_ip(event)

        if not self._charm.unit.is_leader():
            return

        for relation in self._charm.model.relations[self._relation_name]:
            relation.data[self._charm.app]["scrape_metadata"] = json.dumps(self._scrape_metadata)
            relation.data[self._charm.app]["scrape_jobs"] = json.dumps(self._scrape_jobs)

            if alert_groups := self._labeled_alert_groups:
                relation.data[self._charm.app]["alert_rules"] = json.dumps(
                    {"groups": alert_groups}
                )

    def _set_unit_ip(self, event):
        """Set unit host address.

        Each time a metrics provider charm container is restarted it updates its own
        host address in the unit relation data for the Prometheus charm.

        Args:
            event: a `CharmEvent` in response to which each metrics
                endpoint will update its host address.
        """
        for relation in self._charm.model.relations[self._relation_name]:
            relation.data[self._charm.unit]["prometheus_scrape_host"] = str(
                self._charm.model.get_binding(relation).network.bind_address
            )

    def _label_alert_topology(self, rule) -> dict:
        """Insert juju topology labels into an alert rule.

        Args:
            rule: a dictionary representing a Prometheus alert rule.

        Returns:
            a dictionary representing Prometheus alert rule with Juju
            topology labels.
        """
        metadata = self._scrape_metadata
        labels = rule.get("labels", {})
        labels["juju_model"] = metadata["model"]
        labels["juju_model_uuid"] = metadata["model_uuid"]
        labels["juju_application"] = metadata["application"]
        rule["labels"] = labels
        return rule

    def _label_alert_expression(self, rule) -> dict:
        """Insert juju topology filters into a Prometheus alert rule.

        Args:
            rule: a dictionary representing a Prometheus alert rule.

        Returns:
            a dictionary representing a Prometheus alert rule that filters based
            on juju topology.
        """
        metadata = self._scrape_metadata
        topology = 'juju_model="{}", juju_model_uuid="{}", juju_application="{}"'.format(
            metadata["model"], metadata["model_uuid"], metadata["application"]
        )

        if expr := rule.get("expr", None):
            expr = expr.replace("%%juju_topology%%", topology)
            rule["expr"] = expr
        else:
            logger.error("Invalid alert expression in %s", rule.get("alert"))

        return rule

    @property
    def _labeled_alert_groups(self) -> list:
        """Load alert rules from rule files.

        All rules from files for a consumer charm are loaded into a single
        group. The generated name of this group includes Juju topology
        prefixes.

        Returns:
            a list of Prometheus alert rule groups.
        """
        alerts = []
        for path in Path(self._ALERT_RULES_PATH).glob("*.rule"):
            if not path.is_file():
                continue

            logger.debug("Reading alert rule from %s", path)
            with path.open() as rule_file:
                # Load a list of rules from file then add labels and filters
                try:
                    rule = yaml.safe_load(rule_file)
                    rule = self._label_alert_topology(rule)
                    rule = self._label_alert_expression(rule)
                    alerts.append(rule)
                except Exception as e:
                    logger.error("Failed to read alert rules from %s: %s", path.name, str(e))

        # Gather all alerts into a list of one group since Prometheus
        # requires alerts be part of some group
        groups = []
        if alerts:
            metadata = self._scrape_metadata
            group = {
                "name": "{model}_{model_uuid}_{application}_alerts".format(**metadata),
                "rules": alerts,
            }
            groups.append(group)
        return groups

    @property
    def _scrape_jobs(self) -> list:
        """Fetch list of scrape jobs.

        Returns:
           A list of dictionaries, where each dictionary specifies a
           single scrape job for Prometheus.
        """
        default_job = [{"metrics_path": "/metrics"}]
        return self._jobs if self._jobs else default_job

    @property
    def _scrape_metadata(self) -> dict:
        """Generate scrape metadata.

        Returns:
            Scrape configutation metadata for this metrics provider charm.
        """
        metadata = {
            "model": f"{self._charm.model.name}",
            "model_uuid": f"{self._charm.model.uuid}",
            "application": f"{self._charm.model.app.name}",
        }
        return metadata


class MetricsEndpointAggregator(Object):
    """Aggregate metrics from multiple scrape targets.

    `MetricsEndpointAggregator` collects scrape target information from one
    or more related charms and forwards this to a `MetricsEndpointConsumer`
    charm, which may be in a different Juju model. However it is
    essential that `MetricsEndpointAggregator` itself resides in the same
    model as its scrape targets, as this is currently the only way to
    ensure in Juju that the `MetricsEndpointAggregator` will be able to
    determine the model name and uuid of the scrape targets.

    `MetricsEndpointAggregator` should be used in place of
    `MetricsEndpointProvider` in the following two use cases:

    1. Integrating one or more scrape targets that do not support the
    `prometheus_scrape` interface.

    2. Integrating one or more scrape targets through cross model
    relations.

    Using `MetricsEndpointAggregator` to build a Prometheus charm client
    only requires instantiating it. Instantiating
    `MetricsEndpointAggregator` is similar to `MetricsEndpointProvider` except
    that it requires specifying the names of three relations: the
    relation with scrape targets, the relation for alert rules, and
    that with the Prometheus charms. For example

    ```python
    self._aggregator = MetricsEndpointAggregator(
        self,
        {
            "prometheus": "monitoring",
            "scrape_target": "prometheus-target",
            "alert_rules": "prometheus-rules"
        }
    )
    ```

    `MetricsEndpointAggregator` assumes that each unit of a scrape target
    sets in its unit-level relation data two entries with keys
    "hostname" and "port". If it is required to integrate with charms
    that do not honor these assumptions, it is always possible to
    derive from `MetricsEndpointAggregator` overriding the `_get_targets()`
    method, which is responsible for aggregating the unit name, host
    address ("hostname") and port of the scrape target.

    `MetricsEndpointAggregator` also assumes that each unit of a
    scrape target sets in its unit-level relation data a key named
    "groups". The value of this key is expected to be the string
    representation of list of Prometheus Alert rules in YAML format.
    An example of a single such alert rule is

    ```yaml
    - alert: HighRequestLatency
      expr: job:request_latency_seconds:mean5m{job="myjob"} > 0.5
      for: 10m
      labels:
        severity: page
      annotations:
        summary: High request latency
    ```

    Once again if it is required to integrate with charms that do not
    honour these assumptions about alert rules then an object derived
    from `MetricsEndpointAggregator` may be used by overriding the
    `_get_alert_rules()` method.

    `MetricsEndpointAggregator` ensures that Prometheus scrape job
    specifications and alert rules are annotated with Juju topology
    information, just like `MetricsEndpointProvider` and
    `MetricsEndpointConsumer` do.

    By default `MetricsEndpointAggregator` ensures that Prometheus
    "instance" labels refer to Juju topology. This ensures that
    instance labels are stable over unit recreation. While it is not
    advisable to change this option, if required it can be done by
    setting the "relabel_instance" keyword argument to `False` when
    constructing an aggregator object.
    """

    def __init__(self, charm, relation_names, relabel_instance=True):
        super().__init__(charm, relation_names["prometheus"])

        self._charm = charm
        self._target_relation = relation_names["scrape_target"]
        self._prometheus_relation = relation_names["prometheus"]
        self._alert_rules_relation = relation_names["alert_rules"]
        self._relabel_instance = relabel_instance

        # manage Prometheus charm relation events
        prometheus_events = self._charm.on[self._prometheus_relation]
        self.framework.observe(prometheus_events.relation_joined, self._set_prometheus_data)

        # manage list of Prometheus scrape jobs from related scrape targets
        target_events = self._charm.on[self._target_relation]
        self.framework.observe(target_events.relation_changed, self._update_prometheus_jobs)
        self.framework.observe(target_events.relation_departed, self._remove_prometheus_jobs)

        # manage alert rules for Prometheus from related scrape targets
        alert_rule_events = self._charm.on[self._alert_rules_relation]
        self.framework.observe(alert_rule_events.relation_changed, self._update_alert_rules)
        self.framework.observe(alert_rule_events.relation_departed, self._remove_alert_rules)

    def _set_prometheus_data(self, event):
        """Ensure every new Prometheus instances is updated.

        Any time a new Prometheus unit joins the relation with
        `MetricsEndpointAggregator`, that Prometheus unit is provided
        with the complete set of existing scrape jobs and alert rules.
        """
        jobs = []  # list of scrape jobs, one per relation
        for relation in self.model.relations[self._target_relation]:
            if targets := self._get_targets(relation):
                jobs.append(self._static_scrape_job(targets, relation.app.name))

        groups = []  # list of alert rule groups, one group per relation
        for relation in self.model.relations[self._alert_rules_relation]:
            if unit_rules := self._get_alert_rules(relation):
                appname = relation.app.name
                rules = self._label_alert_rules(unit_rules, appname)
                group = {"name": self._group_name(appname), "rules": rules}
                groups.append(group)

        event.relation.data[self._charm.app]["scrape_jobs"] = json.dumps(jobs)
        event.relation.data[self._charm.app]["alert_rules"] = json.dumps({"groups": groups})

    def _update_prometheus_jobs(self, event):
        """Update scrape jobs in response to scrape target changes.

        When there is any change in relation data with any scrape
        target, the Prometheus scrape job, for that specific target is
        updated.
        """
        if not (targets := self._get_targets(event.relation)):
            return

        # new scrape job for the relation that has changed
        updated_job = self._static_scrape_job(targets, event.relation.app.name)

        for relation in self.model.relations[self._prometheus_relation]:
            jobs = json.loads(relation.data[self._charm.app].get("scrape_jobs", "[]"))
            # list of scrape jobs that have not chagned
            jobs = [job for job in jobs if updated_job["job_name"] != job["job_name"]]
            jobs.append(updated_job)
            relation.data[self._charm.app]["scrape_jobs"] = json.dumps(jobs)

    def _remove_prometheus_jobs(self, event):
        """Remove scrape jobs when a target departs.

        Any time a scrape target departs, any Prometheus scrape job
        associated with that specific scrape target is removed.
        """
        job_name = self._job_name(event.relation.app.name)
        unit_name = event.unit.name

        for relation in self.model.relations[self._prometheus_relation]:
            if not (jobs := json.loads(relation.data[self._charm.app].get("scrape_jobs", "[]"))):
                continue

            if not (changed_job := [j for j in jobs if j.get("job_name") == job_name]):
                continue
            changed_job = changed_job[0]

            # list of scrape jobs that have not changed
            jobs = [job for job in jobs if job.get("job_name") != job_name]

            # list of scrape jobs for units of the same application that still exist
            configs_kept = [
                config
                for config in changed_job["static_configs"]
                if config.get("labels", {}).get("juju_unit") != unit_name
            ]

            if configs_kept:
                changed_job["static_configs"] = configs_kept
                jobs.append(changed_job)

            relation.data[self._charm.app]["scrape_jobs"] = json.dumps(jobs)

    def _update_alert_rules(self, event):
        """Update alert rules in response to scrape target changes.

        When there is any change in alert rule relation data for any
        scrape target, the list of alert rules for that specific
        target is updated.
        """
        if not (unit_rules := self._get_alert_rules(event.relation)):
            return

        appname = event.relation.app.name
        rules = self._label_alert_rules(unit_rules, appname)
        # the alert rule group that has changed
        updated_group = {"name": self._group_name(appname), "rules": rules}

        for relation in self.model.relations[self._prometheus_relation]:
            alert_rules = json.loads(relation.data[self._charm.app].get("alert_rules", "{}"))
            groups = alert_rules.get("groups", [])
            # list of alert rule groups that have not changed
            groups = [group for group in groups if updated_group["name"] != group["name"]]
            groups.append(updated_group)
            relation.data[self._charm.app]["alert_rules"] = json.dumps({"groups": groups})

    def _remove_alert_rules(self, event):
        """Remove alert rules for departed targets.

        Any time a scrape target departs any alert rules associated
        with that specific scrape target is removed.
        """
        group_name = self._group_name(event.relation.app.name)
        unit_name = event.unit.name

        for relation in self.model.relations[self._prometheus_relation]:
            alert_rules = json.loads(relation.data[self._charm.app].get("alert_rules", "{}"))
            if not alert_rules:
                continue

            if not (groups := alert_rules.get("groups", [])):
                continue

            changed_group = [group for group in groups if group["name"] == group_name]
            if not changed_group:
                continue
            changed_group = changed_group[0]

            # list of alert rule groups that have not changed
            groups = [group for group in groups if group["name"] != group_name]

            # list of alert rules not associated with departing unit
            rules_kept = [
                rule
                for rule in changed_group.get("rules")
                if rule.get("labels").get("juju_unit") != unit_name
            ]

            if rules_kept:
                changed_group["rules"] = rules_kept
                groups.append(changed_group)

            relation.data[self._charm.app]["alert_rules"] = (
                json.dumps({"groups": groups}) if groups else "{}"
            )

    def _get_targets(self, relation) -> dict:
        """Fetch scrape targets for a relation.

        Scrape target information is returned for each unit in the
        relation. This information contains the unit name, network
        hostname (or address) for that unit, and port on which an
        metrics endpoint is exposed in that unit.

        Args:
            relation: an `ops.model.Relation` object for which scrape
                targets are required.

        Returns:
            a dictionary whose keys are names of the units in the
            relation. There values associated with each key is itself
            a dictionary of the form
            ```
            {"hostname": hostname, "port": port}
            ```
        """
        targets = {}
        for unit in relation.units:
            port = relation.data[unit].get("port", 80)
            if hostname := relation.data[unit].get("hostname"):
                targets.update({unit.name: {"hostname": hostname, "port": port}})

        return targets

    def _get_alert_rules(self, relation) -> dict:
        """Fetch alert rules for a relation.

        Each unit of the related scrape target may have its own
        associated alert rules. Alert rules for all units are returned
        indexed by unit name.

        Args:
            relation: an `ops.model.Relation` object for which alert
                rules are required.

        Returns:
            a dictionary whose keys are names of the units in the
            relation. There values associated with each key is a list
            of alert rules. Each rule is in dictionary format. The
            structure "rule dictionary" corresponds to single
            Prometheus alert rule.
        """
        rules = {}
        for unit in relation.units:
            if unit_rules := yaml.safe_load(relation.data[unit].get("groups", "")):
                rules.update({unit.name: unit_rules})

        return rules

    def _job_name(self, appname) -> str:
        """Construct a scrape job name.

        Each relation has its own unique scrape job name. All units in
        the relation are scraped as part of the same scrape job.

        Args:
            appname: string name of a related application.

        Retruns:
            a string Prometheus scrape job name for the application.
        """
        return f"juju_{self.model.name}_{self.model.uuid[:7]}_{appname}_prometheus_scrape"

    def _group_name(self, appname) -> str:
        """Construct name for an alert rule group.

        Each unit in a relation may define its own alert rules. All
        rules, for all units in a relation are grouped together and
        given a single alert rule group name.

        Args:
            appname: string name of a related application.

        Retruns:
            a string Prometheus alert rules group name for the application.
        """
        return f"juju_{self.model.name}_{self.model.uuid[:7]}_{appname}_alert_rules"

    def _juju_topology(self, unit_name, appname) -> dict:
        """Construct juju topology labels.

        Args:
            unit_name: a string name of a unit.
            appname: a string name of an application.

        Returns:
            a dictionary containing Juju topology labels.
        """
        return {
            "juju_model": self.model.name,
            "juju_model_uuid": self.model.uuid[:7],
            "juju_application": appname,
            "juju_unit": unit_name,
        }

    def _label_alert_rules(self, unit_rules, appname) -> list:
        """Apply juju topology labels to alert rules.

        Args:
            unit_rules: a list of alert rules, where each rule is in
                dictionary format.
            appname: a string name of the application to which the
                alert rules belong.

        Returns:
            a list of alert rules with Juju topology labels.
        """
        labeled_rules = []
        for unit_name, rules in unit_rules.items():
            for rule in rules:
                rule["labels"].update(self._juju_topology(unit_name, appname))
                labeled_rules.append(rule)

        return labeled_rules

    def _static_scrape_job(self, targets, application_name) -> dict:
        """Construct a static scrape job for an application.

        Args:
            targets: a dictionary providing hostname and port for all
                scrape target. The keys of this dictionary are unit
                names. Values corresponding to these keys are
                themselves a dictionary with keys "hostname" and
                "port".
            application_name: a string name of the application for
                which this static scrape job is being constructed.


        Returns:
            A dictionary corresponding to a Prometheus static scrape
            job configuration for one application. The returned
            dictionary may be transformed into YAML and appended to
            the list of any existing list of Prometheus static configs.
        """
        juju_model = self.model.name
        juju_model_uuid = self.model.uuid
        job = {
            "job_name": self._job_name(application_name),
            "static_configs": [
                {
                    "targets": [f"{target['hostname']}:{target['port']}"],
                    "labels": {
                        "juju_model": juju_model,
                        "juju_model_uuid": juju_model_uuid,
                        "juju_application": application_name,
                        "juju_unit": unit_name,
                        "host": target["hostname"],
                    },
                }
                for unit_name, target in targets.items()
            ],
            "relabel_configs": self._relabel_configs,
        }

        return job

    @property
    def _relabel_configs(self) -> list:
        """Create Juju topology relabeling configuation.

        Using Juju topology for instance labels ensures that these
        labels are stable across unit recreation.

        Returns:
            a list of Prometheus relabling configurations. Each item in
            this list is one relabel configuration.
        """
        return (
            [
                {
                    "source_labels": [
                        "juju_model",
                        "juju_model_uuid",
                        "juju_application",
                        "juju_unit",
                    ],
                    "separator": "_",
                    "target_label": "instance",
                    "regex": "(.*)",
                }
            ]
            if self._relabel_instance
            else []
        )
