Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions interfaces/otlp/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [0.1.0]

### Added

- Initial release of charmlibs.interfaces.otlp
- `OtlpRequirer` for consuming OTLP endpoints from a provider relation
- `OtlpProvider` for publishing OTLP endpoints to requirer relations
Expand All @@ -20,4 +21,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Generic aggregator rules automatically included in every requirer's published rule set
- Python 3.10+ compatibility

## [0.2.0]

### Updated

- Replace the requirer's rule path interface with an interface accepting an object containing rules
17 changes: 10 additions & 7 deletions interfaces/otlp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,9 @@ OTLP integration library for Juju charms, providing OTLP endpoint information fo

## Features

- **Provider/Requirer pattern**: Enables charms to share OTLP endpoint information and rules
- **Provider/Requirer pattern**: Enables charms to share OTLP endpoint information and rules content
- **Define endpoint support**: Providers and requirers define what OTLP protocols and telemetries they support.
- **Rules interface**: Add rules to a 'RuleStore' object and provide that to the requirer for publishing.
- **Automatic topology injection**: Inject Juju topology labels into rule expressions and labels with metadata if the labels are not already labeled.

## Getting started
Expand Down Expand Up @@ -48,7 +49,7 @@ class MyOtlpServer(CharmBase):
### Requirer Side

```python
from charmlibs.interfaces.otlp import OtlpRequirer
from charmlibs.interfaces.otlp import OtlpRequirer, RulesStore

class MyOtlpSender(CharmBase):
def __init__(self, framework: ops.Framework):
Expand All @@ -57,11 +58,13 @@ class MyOtlpSender(CharmBase):
self.framework.observe(self.on.update_status, self._publish_rules)

def _publish_rules(self, _: ops.EventBase):
OtlpRequirer(
self,
loki_rules_path="./src/loki_alert_rules",
prometheus_rules_path="./src/prometheus_alert_rules",
).publish()
rules = (
RuleStore(JujuTopology.from_charm(self))
.add_logql(SINGLE_LOGQL_ALERT, group_name='test_logql_alert')
.add_promql(SINGLE_PROMQL_RECORD, group_name='test_promql_record')
.add_logql(OFFICIAL_LOGQL_RULES)
)
OtlpRequirer(self, rules=rules).publish()

def _access_endpoints(self, _: ops.EventBase):
OtlpRequirer(
Expand Down
34 changes: 19 additions & 15 deletions interfaces/otlp/src/charmlibs/interfaces/otlp/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
`requirements <https://github.com/open-telemetry/opentelemetry-proto/blob/main/docs/requirements.md>`_
of the project.

This library provides a way for charms to share OTLP endpoint information and associated Loki and
This library provides a way for charms to share OTLP endpoint information, and associated Loki and
Prometheus rules. This library requires that the charm's workload already supports
sending/receiving OTLP data and focuses on communicating those endpoints.

Expand Down Expand Up @@ -96,25 +96,24 @@ def _access_endpoints(self, _: ops.EventBase):
That means an endpoint supporting the `gRPC` protocol will be selected over one supporting `HTTP`.
Unknown protocols will receive the lowest priority.

The OtlpRequirer also publishes rules to related OtlpProvider charms with the ``publish()``
method::
The OtlpRequirer also publishes user-defined and generic (applied to all charms) rules to related
OtlpProvider charms with the ``publish()`` method::

from charmlibs.interfaces.otlp import OtlpRequirer
from charmlibs.interfaces.otlp import OtlpRequirer, RulesStore

class MyOtlpSender(CharmBase):
def __init__(self, framework: ops.Framework):
super().__init__(framework)
self.framework.observe(self.on.update_status, self._publish_rules)

def _publish_rules(self, _: ops.EventBase):
OtlpRequirer(
self,
loki_rules_path="./src/loki_alert_rules",
prometheus_rules_path="./src/prometheus_alert_rules",
).publish()

It is the charm's responsibility to manage the rules in the ``loki_rules_path`` and
``prometheus_rules_path`` directories, which will be forwarded to the related OtlpProvider charms.
rules = (
RuleStore(JujuTopology.from_charm(self))
.add_logql(SINGLE_LOGQL_ALERT, group_name='test_logql_alert')
.add_promql(SINGLE_PROMQL_RECORD, group_name='test_promql_record')
.add_logql(OFFICIAL_LOGQL_RULES)
)
OtlpRequirer(self, rules=rules).publish()

Relation Data Format
====================
Expand All @@ -135,14 +134,17 @@ def _publish_rules(self, _: ops.EventBase):
},
]

The OtlpRequirer offers compressed rules in the relation databag under the ``rules`` key. The
charm's metadata is included under the ``metadata`` key for the provider to know the source of the
rules::
The OtlpRequirer offers compressed rules in the relation databag under the ``rules`` key, which
have this structure when decompressed::

"rules": {
"promql": {...},
"logql": {...},
}

The charm's metadata is included under the ``metadata`` key for the provider to know the source of
the rules::

"metadata": {
"model": "my-model",
"model_uuid": "f4d59020-c8e7-4053-8044-a2c1e5591c7f",
Expand All @@ -156,6 +158,7 @@ def _publish_rules(self, _: ops.EventBase):
OtlpEndpoint,
OtlpProvider,
OtlpRequirer,
RuleStore,
)
from ._version import __version__ as __version__

Expand All @@ -165,4 +168,5 @@ def _publish_rules(self, _: ops.EventBase):
'OtlpEndpoint',
'OtlpProvider',
'OtlpRequirer',
'RuleStore',
]
99 changes: 60 additions & 39 deletions interfaces/otlp/src/charmlibs/interfaces/otlp/_otlp.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,12 @@
import logging
from collections import OrderedDict
from collections.abc import Sequence
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Final, Literal

from cosl.juju_topology import JujuTopology
from cosl.rules import AlertRules, InjectResult, generic_alert_groups
from cosl.rules import InjectResult, Rules, generic_alert_groups
from cosl.types import OfficialRuleFileFormat
from cosl.utils import LZMABase64
from ops import CharmBase
Expand All @@ -49,6 +50,47 @@
logger = logging.getLogger(__name__)


@dataclass
class RuleStore:
"""An API for users to provide rules of different types to the OtlpRequirer."""

topology: JujuTopology
logql: Rules = field(init=False)
promql: Rules = field(init=False)

def __post_init__(self):
self.logql = Rules(query_type='logql', topology=self.topology)
self.promql = Rules(query_type='promql', topology=self.topology)

def add_logql(
self,
rule_dict: dict[str, Any],
*,
group_name: str | None = None,
group_name_prefix: str | None = None,
) -> 'RuleStore':
self.logql.add(rule_dict, group_name=group_name, group_name_prefix=group_name_prefix)
return self

def add_logql_path(self, dir_path: str | Path, *, recursive: bool = False) -> 'RuleStore':
self.logql.add_path(dir_path, recursive=recursive)
return self

def add_promql(
self,
rule_dict: dict[str, Any],
*,
group_name: str | None = None,
group_name_prefix: str | None = None,
) -> 'RuleStore':
self.promql.add(rule_dict, group_name=group_name, group_name_prefix=group_name_prefix)
return self

def add_promql_path(self, dir_path: str | Path, *, recursive: bool = False) -> 'RuleStore':
self.promql.add_path(dir_path, recursive=recursive)
return self


class _RulesModel(BaseModel):
"""Rules of various formats (query languages) to support in the relation databag."""

Expand Down Expand Up @@ -132,10 +174,8 @@ class OtlpRequirer:
endpoints.
telemetries: The telemetries to filter for in the provider's OTLP
endpoints.
loki_rules_path: The path to Loki alerting and recording rules provided
by this charm.
prometheus_rules_path: The path to Prometheus alerting and recording
rules provided by this charm.
rules: Rules of different types e.g., logql or promql, that the
requirer will publish for the provider.
"""

def __init__(
Expand All @@ -145,20 +185,18 @@ def __init__(
protocols: Sequence[Literal['http', 'grpc']] | None = None,
telemetries: Sequence[Literal['logs', 'metrics', 'traces']] | None = None,
*,
loki_rules_path: str | Path = DEFAULT_LOKI_RULES_RELATIVE_PATH,
prometheus_rules_path: str | Path = DEFAULT_PROM_RULES_RELATIVE_PATH,
rules: RuleStore | None = None,
):
self._charm = charm
self._topology = JujuTopology.from_charm(charm)
self._relation_name = relation_name
self._protocols: list[Literal['http', 'grpc']] = (
list(protocols) if protocols is not None else []
)
self._telemetries: list[Literal['logs', 'metrics', 'traces']] = (
list(telemetries) if telemetries is not None else []
)
self._topology = JujuTopology.from_charm(charm)
self._loki_rules_path: str | Path = loki_rules_path
self._prom_rules_path: str | Path = prometheus_rules_path
self._rules = rules if rules is not None else RuleStore(self._topology)

def _filter_endpoints(self, endpoints: list[_OtlpEndpoint]) -> list[_OtlpEndpoint]:
"""Filter out unsupported OtlpEndpoints.
Expand Down Expand Up @@ -200,42 +238,25 @@ def _favor_modern_endpoints(self, endpoints: list[_OtlpEndpoint]) -> _OtlpEndpoi
def publish(self):
"""Triggers programmatically the update of the relation data.

The rule files exist in separate directories, distinguished by format
(logql|promql), each including alerting and recording rule types. The
charm uses these paths as aggregation points for rules, acting as their
source of truth. For each type of rule, the charm may aggregate rules
from:

- rules bundled in the charm's source code
- any rules provided by related charms

Generic, injected rules (not specific to any charm) are always
published. Besides these generic rules, the inclusion of bundled rules
and rules from related charms is the responsibility of the charm using
the library. Including bundled rules and rules from related charms is
achieved by copying these rules to the respective paths within the
charm's filesystem and providing those paths to the OtlpRequirer
constructor.
These rule sources are included when publishing:
- Any rules provided at the instantiation of this class.
- Generic (not specific to any charm) PromQL rules.
"""
if not self._charm.unit.is_leader():
# Only the leader unit can write to app data.
return

# Define the rule types
loki_rules = AlertRules(query_type='logql', topology=self._topology)
prom_rules = AlertRules(query_type='promql', topology=self._topology)

# Add rules
prom_rules.add(
self._rules.add_promql(
copy.deepcopy(generic_alert_groups.aggregator_rules),
group_name_prefix=self._topology.identifier,
)
loki_rules.add_path(self._loki_rules_path, recursive=True)
prom_rules.add_path(self._prom_rules_path, recursive=True)

# Publish to databag
databag = _OtlpRequirerAppData.model_validate({
'rules': {'logql': loki_rules.as_dict(), 'promql': prom_rules.as_dict()},
'rules': {
'logql': self._rules.logql.as_dict(),
'promql': self._rules.promql.as_dict(),
},
'metadata': self._topology.as_dict(),
})
for relation in self._charm.model.relations[self._relation_name]:
Expand Down Expand Up @@ -312,7 +333,7 @@ def publish(self) -> None:
for relation in self._charm.model.relations[self._relation_name]:
relation.save(databag, self._charm.app)

def rules(self, query_type: Literal['logql', 'promql']) -> dict[str, dict[str, Any]]:
def rules(self, query_type: Literal['logql', 'promql']) -> dict[str, OfficialRuleFileFormat]:
"""Fetch rules for all relations of the desired query and rule types.

This method returns all rules of the desired query and rule types
Expand All @@ -327,9 +348,9 @@ def rules(self, query_type: Literal['logql', 'promql']) -> dict[str, dict[str, A
a mapping of relation ID to a dictionary of alert rule groups
following the OfficialRuleFileFormat from cos-lib.
"""
rules_map: dict[str, dict[str, Any]] = {}
# Instantiate AlertRules with topology to ensure that rules always have an identifier
rules_obj = AlertRules(query_type, self._topology)
rules_map: dict[str, OfficialRuleFileFormat] = {}
# Instantiate Rules with topology to ensure that rules always have an identifier
rules_obj = Rules(query_type, self._topology)
for relation in self._charm.model.relations[self._relation_name]:
if not relation.data[relation.app]:
# The databags haven't initialized yet, continue
Expand Down
36 changes: 14 additions & 22 deletions interfaces/otlp/tests/unit/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,17 +18,18 @@

import logging
import socket
from copy import deepcopy
from typing import Final, Literal
from unittest.mock import patch

import ops
import pytest
from cosl.juju_topology import JujuTopology
from ops import testing
from ops.charm import CharmBase

from charmlibs.interfaces.otlp import OtlpProvider, OtlpRequirer
from charmlibs.interfaces.otlp._otlp import DEFAULT_REQUIRER_RELATION_NAME as SEND
from helpers import add_alerts, patch_cos_tool_path
from charmlibs.interfaces.otlp import OtlpProvider, OtlpRequirer, RuleStore
from helpers import patch_cos_tool_path

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -81,30 +82,21 @@
class OtlpRequirerCharm(CharmBase):
def __init__(self, framework: ops.Framework):
super().__init__(framework)
self.charm_root = self.charm_dir.absolute()
self.loki_rules_path = self.charm_root.joinpath(*LOKI_RULES_DEST_PATH.split('/'))
self.prometheus_rules_path = self.charm_root.joinpath(*METRICS_RULES_DEST_PATH.split('/'))
self.framework.observe(self.on.update_status, self._publish_rules)

def _add_rules_to_disk(self):
def _publish_rules(self, _: ops.EventBase) -> None:
with patch_cos_tool_path():
add_alerts(
alerts={'test_identifier': OFFICIAL_LOGQL_RULES}, dest_path=self.loki_rules_path
)
add_alerts(
alerts={'test_identifier': OFFICIAL_PROMQL_RULES},
dest_path=self.prometheus_rules_path,
rules = (
RuleStore(JujuTopology.from_charm(self))
.add_logql(deepcopy(SINGLE_LOGQL_ALERT), group_name='test_logql_alert')
.add_logql(deepcopy(SINGLE_LOGQL_RECORD), group_name='test_logql_record')
.add_promql(deepcopy(SINGLE_PROMQL_ALERT), group_name='test_promql_alert')
.add_promql(deepcopy(SINGLE_PROMQL_RECORD), group_name='test_promql_record')
.add_logql(deepcopy(OFFICIAL_LOGQL_RULES))
.add_promql(deepcopy(OFFICIAL_PROMQL_RULES))
)

def _publish_rules(self, _: ops.EventBase) -> None:
self._add_rules_to_disk()
OtlpRequirer(
self,
SEND,
ALL_PROTOCOLS,
ALL_TELEMETRIES,
loki_rules_path=self.loki_rules_path,
prometheus_rules_path=self.prometheus_rules_path,
self, protocols=ALL_PROTOCOLS, telemetries=ALL_TELEMETRIES, rules=rules
).publish()


Expand Down
Loading
Loading