diff --git a/interfaces/otlp/README.md b/interfaces/otlp/README.md index 7091f0b9..a6f7429e 100644 --- a/interfaces/otlp/README.md +++ b/interfaces/otlp/README.md @@ -26,25 +26,23 @@ from charmlibs.interfaces.otlp import OtlpProvider class MyOtlpServer(CharmBase): def __init__(self, *args): super().__init__(*args) - self.otlp_provider = OtlpProvider(self) - self.framework.observe(self.on.ingress_ready, self._on_ingress_ready) - - def _on_ingress_ready(self, event): - self.otlp_provider.add_endpoint( - protocol="grpc", - endpoint="https://my-app.ingress:4317", - telemetries=["logs", "metrics"], - ) - self.otlp_provider.add_endpoint( - protocol="http", - endpoint="https://my-app.ingress:4318", - telemetries=["traces"], - ) - # publish the registered endpoints to the relation databag - self.otlp_provider.publish() - # optionally, get the alerting and recording rules - promql_rules = self.otlp_provider.rules("promql") - logql_rules = self.otlp_provider.rules("logql") + self.framework.observe(self.on.ingress_ready, self._publish_endpoints) + self.framework.observe(self.on.update_status, self._access_rules) + + def _publish_endpoints(self, event): + OtlpProvider(self).add_endpoint( + protocol="grpc", + endpoint="https://my-app.ingress:4317", + telemetries=["logs", "metrics"], + ).add_endpoint( + protocol="http", + endpoint="https://my-app.ingress:4318", + telemetries=["traces"], + ).publish() + + def _access_rules(self, event): + OtlpProvider(self).rules("promql") + OtlpProvider(self).rules("logql") ``` ### Requirer Side @@ -53,22 +51,24 @@ class MyOtlpServer(CharmBase): from charmlibs.interfaces.otlp import OtlpRequirer class MyOtlpSender(CharmBase): - def __init__(self, *args): - super().__init__(*args) - self.otlp_requirer = OtlpRequirer( + def __init__(self, framework: ops.Framework): + super().__init__(framework) + self.framework.observe(self.on.update_status, self._access_endpoints) + self.framework.observe(self.on.update_status, self._publish_rules) + + def _publish_rules(self, _: ops.EventBase): + OtlpRequirer( self, - protocols=["grpc", "http"], - telemetries=["logs", "metrics", "traces"], loki_rules_path="./src/loki_alert_rules", prometheus_rules_path="./src/prometheus_alert_rules", - ) - self.framework.observe(self.on.update_status, self._reconcile) - - def _reconcile(self, event): - # publish the rules to the relation databag - self.otlp_requirer.publish() - # get the endpoints from the provider - supported_endpoints = self.otlp_requirer.endpoints + ).publish() + + def _access_endpoints(self, _: ops.EventBase): + OtlpRequirer( + self, + protocols=["grpc", "http"], + telemetries=["logs", "metrics", "traces"], + ).endpoints ``` ## Documentation diff --git a/interfaces/otlp/src/charmlibs/interfaces/otlp/__init__.py b/interfaces/otlp/src/charmlibs/interfaces/otlp/__init__.py index 2896f84c..7d9809f4 100644 --- a/interfaces/otlp/src/charmlibs/interfaces/otlp/__init__.py +++ b/interfaces/otlp/src/charmlibs/interfaces/otlp/__init__.py @@ -38,21 +38,18 @@ class MyOtlpServer(CharmBase): def __init__(self, *args): super().__init__(*args) - self.otlp_provider = OtlpProvider(self) - self.framework.observe(self.on.ingress_ready, self._on_ingress_ready) + self.framework.observe(self.on.ingress_ready, self._publish_endpoints) - def _on_ingress_ready(self, event): - self.otlp_provider.add_endpoint( + def _publish_endpoints(self, event): + OtlpProvider(self).add_endpoint( protocol="grpc", endpoint="https://my-app.ingress:4317", telemetries=["logs", "metrics"], - ) - self.otlp_provider.add_endpoint( + ).add_endpoint( protocol="http", endpoint="https://my-app.ingress:4318", telemetries=["traces"], - ) - self.otlp_provider.publish() + ).publish() Providers add endpoints explicitly; nothing is auto-published by default. Make sure to add endpoints and publish them after the charm's endpoint details have been updated e.g., ingress or @@ -61,9 +58,16 @@ def _on_ingress_ready(self, event): The OtlpProvider also consumes rules from related OtlpRequirer charms, which can be retrieved with the ``rules()`` method:: - # snip ... - promql_rules = self.otlp_provider.rules("promql") - logql_rules = self.otlp_provider.rules("logql") + from charmlibs.interfaces.otlp import OtlpProvider + + class MyOtlpServer(CharmBase): + def __init__(self, *args): + super().__init__(*args) + self.framework.observe(self.on.update_status, self._access_rules) + + def _access_rules(self, event): + OtlpProvider(self).rules("promql") + OtlpProvider(self).rules("logql") Requirer Side (Charms requiring OTLP endpoints) ----------------------------------------------- @@ -74,19 +78,16 @@ def _on_ingress_ready(self, event): from charmlibs.interfaces.otlp import OtlpRequirer class MyOtlpSender(CharmBase): - def __init__(self, *args): - super().__init__(*args) - self.otlp_requirer = OtlpRequirer( + def __init__(self, framework: ops.Framework): + super().__init__(framework) + self.framework.observe(self.on.update_status, self._access_endpoints) + + def _access_endpoints(self, _: ops.EventBase): + OtlpRequirer( self, protocols=["grpc", "http"], telemetries=["logs", "metrics", "traces"], - loki_rules_path="./src/loki_alert_rules", - prometheus_rules_path="./src/prometheus_alert_rules", - ) - self.framework.observe(self.on.update_status, self._reconcile) - - def _reconcile(self, event): - supported_endpoints = self.otlp_requirer.endpoints + ).endpoints Given the defined, supported protocols and telemetries, the OtlpRequirer will filter out unsupported endpoints and prune unsupported telemetries. After filtering, requirer selection @@ -98,8 +99,19 @@ def _reconcile(self, event): The OtlpRequirer also publishes rules to related OtlpProvider charms with the ``publish()`` method:: - # snip ... - self.otlp_requirer.publish() + from charmlibs.interfaces.otlp import OtlpRequirer + + 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. @@ -135,7 +147,7 @@ def _reconcile(self, event): "model": "my-model", "model_uuid": "f4d59020-c8e7-4053-8044-a2c1e5591c7f", "application": "my-app", - "charm": "my-charm", + "charm_name": "my-charm", "unit": "my-charm/0", } """ diff --git a/interfaces/otlp/src/charmlibs/interfaces/otlp/_otlp.py b/interfaces/otlp/src/charmlibs/interfaces/otlp/_otlp.py index b1d067d7..100267a3 100644 --- a/interfaces/otlp/src/charmlibs/interfaces/otlp/_otlp.py +++ b/interfaces/otlp/src/charmlibs/interfaces/otlp/_otlp.py @@ -80,7 +80,7 @@ class _OtlpEndpoint(OtlpEndpoint): """A pydantic model for a single OTLP endpoint.""" -class OtlpProviderAppData(BaseModel): +class _OtlpProviderAppData(BaseModel): """A pydantic model for the OTLP provider's app databag.""" endpoints: list[_OtlpEndpoint] = Field( @@ -88,7 +88,7 @@ class OtlpProviderAppData(BaseModel): ) -class OtlpRequirerAppData(BaseModel): +class _OtlpRequirerAppData(BaseModel): """A pydantic model for the OTLP requirer's app databag. The rules are compressed when saved to databag to avoid hitting databag @@ -234,7 +234,7 @@ def publish(self): prom_rules.add_path(self._prom_rules_path, recursive=True) # Publish to databag - databag = OtlpRequirerAppData.model_validate({ + databag = _OtlpRequirerAppData.model_validate({ 'rules': {'logql': loki_rules.as_dict(), 'promql': prom_rules.as_dict()}, 'metadata': self._topology.as_dict(), }) @@ -259,11 +259,10 @@ def endpoints(self) -> dict[int, OtlpEndpoint]: continue try: - provider = relation.load(OtlpProviderAppData, relation.app) + provider = relation.load(_OtlpProviderAppData, relation.app) except ValidationError as e: logger.error('OTLP databag failed validation: %s', e) continue - if endpoints := self._filter_endpoints(provider.endpoints): endpoint_map[relation.id] = self._favor_modern_endpoints(endpoints) @@ -293,7 +292,7 @@ def add_endpoint( protocol: Literal['http', 'grpc'], endpoint: str, telemetries: Sequence[Literal['logs', 'metrics', 'traces']], - ): + ) -> 'OtlpProvider': """Add an OtlpEndpoint to the list of endpoints to publish. Call this method after endpoint-changing events e.g. TLS and ingress. @@ -301,6 +300,7 @@ def add_endpoint( self._endpoints.append( _OtlpEndpoint(protocol=protocol, endpoint=endpoint, telemetries=telemetries) ) + return self def publish(self) -> None: """Triggers programmatically the update of the relation data.""" @@ -308,11 +308,11 @@ def publish(self) -> None: # Only the leader unit can write to app data. return - databag = OtlpProviderAppData.model_validate({'endpoints': self._endpoints}) + databag = _OtlpProviderAppData.model_validate({'endpoints': self._endpoints}) for relation in self._charm.model.relations[self._relation_name]: relation.save(databag, self._charm.app) - def rules(self, query_type: Literal['logql', 'promql']): + def rules(self, query_type: Literal['logql', 'promql']) -> dict[str, dict[str, Any]]: """Fetch rules for all relations of the desired query and rule types. This method returns all rules of the desired query and rule types @@ -336,7 +336,7 @@ def rules(self, query_type: Literal['logql', 'promql']): continue try: - requirer = relation.load(OtlpRequirerAppData, relation.app) + requirer = relation.load(_OtlpRequirerAppData, relation.app) except ValidationError as e: logger.error('OTLP databag failed validation: %s', e) continue diff --git a/interfaces/otlp/tests/unit/conftest.py b/interfaces/otlp/tests/unit/conftest.py index a7c8bb5c..211cad1a 100644 --- a/interfaces/otlp/tests/unit/conftest.py +++ b/interfaces/otlp/tests/unit/conftest.py @@ -18,7 +18,7 @@ import logging import socket -from typing import cast +from typing import Final, Literal from unittest.mock import patch import ops @@ -27,73 +27,96 @@ 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 logger = logging.getLogger(__name__) LOKI_RULES_DEST_PATH = 'loki_alert_rules' METRICS_RULES_DEST_PATH = 'prometheus_alert_rules' +SINGLE_LOGQL_ALERT: Final = { + 'alert': 'HighLogVolume', + 'expr': 'count_over_time({job=~".+"}[30s]) > 100', + 'labels': {'severity': 'high'}, +} +SINGLE_LOGQL_RECORD: Final = { + 'record': 'log:error_rate:rate5m', + 'expr': 'sum by (service) (rate({job=~".+"} | json | level="error" [5m]))', + 'labels': {'severity': 'high'}, +} +SINGLE_PROMQL_ALERT: Final = { + 'alert': 'Workload Missing', + 'expr': 'up{job=~".+"} == 0', + 'for': '0m', + 'labels': {'severity': 'critical'}, +} +SINGLE_PROMQL_RECORD: Final = { + 'record': 'code:prometheus_http_requests_total:sum', + 'expr': 'sum by (code) (prometheus_http_requests_total{job=~".+"})', + 'labels': {'severity': 'high'}, +} +OFFICIAL_LOGQL_RULES: Final = { + 'groups': [ + { + 'name': 'test_logql', + 'rules': [SINGLE_LOGQL_ALERT, SINGLE_LOGQL_RECORD], + }, + ] +} +OFFICIAL_PROMQL_RULES: Final = { + 'groups': [ + { + 'name': 'test_promql', + 'rules': [SINGLE_PROMQL_ALERT, SINGLE_PROMQL_RECORD], + }, + ] +} +ALL_PROTOCOLS: Final[list[Literal['grpc', 'http']]] = ['grpc', 'http'] +ALL_TELEMETRIES: Final[list[Literal['logs', 'metrics', 'traces']]] = ['logs', 'metrics', 'traces'] + # --- Tester charms --- class OtlpRequirerCharm(CharmBase): - def __init__(self, framework: ops.Framework): - super().__init__(framework) - self.otlp_requirer = OtlpRequirer( - self, protocols=['http', 'grpc'], telemetries=['metrics', 'logs'] - ) - self.framework.observe(self.on.update_status, self._on_update_status) - - def _on_update_status(self, event: ops.EventBase) -> None: - self.otlp_requirer.publish() - - -class OtlpProviderCharm(CharmBase): - def __init__(self, framework: ops.Framework): - super().__init__(framework) - self.otlp_provider = OtlpProvider(self) - self.framework.observe(self.on.update_status, self._on_update_status) - - def _on_update_status(self, event: ops.EventBase) -> None: - self.otlp_provider.add_endpoint( - protocol='http', endpoint=f'{socket.getfqdn()}:4318', telemetries=['metrics'] - ) - self.otlp_provider.publish() - - -class OtlpDualCharm(CharmBase): def __init__(self, framework: ops.Framework): super().__init__(framework) self.charm_root = self.charm_dir.absolute() - self.otlp_requirer = OtlpRequirer( - self, - protocols=['http', 'grpc'], - telemetries=['metrics', 'logs'], - loki_rules_path=self.charm_root.joinpath(*LOKI_RULES_DEST_PATH.split('/')), - prometheus_rules_path=self.charm_root.joinpath(*METRICS_RULES_DEST_PATH.split('/')), - ) - self.otlp_provider = OtlpProvider(self) - self.framework.observe(self.on.update_status, self._on_update_status) - - def _on_update_status(self, event: ops.EventBase) -> None: - forward_alert_rules = cast('bool', self.config.get('forward_alert_rules')) - self.otlp_provider.add_endpoint( - protocol='http', endpoint=f'{socket.getfqdn()}:4318', telemetries=['metrics'] - ) + 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): with patch_cos_tool_path(): add_alerts( - alerts=self.otlp_provider.rules('logql') if forward_alert_rules else {}, - dest_path=self.charm_root.joinpath(*LOKI_RULES_DEST_PATH.split('/')), + alerts={'test_identifier': OFFICIAL_LOGQL_RULES}, dest_path=self.loki_rules_path ) add_alerts( - alerts=self.otlp_provider.rules('promql') if forward_alert_rules else {}, - dest_path=self.charm_root.joinpath(*METRICS_RULES_DEST_PATH.split('/')), + alerts={'test_identifier': OFFICIAL_PROMQL_RULES}, + dest_path=self.prometheus_rules_path, ) - self.otlp_provider.publish() - self.otlp_requirer.publish() + 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, + ).publish() + + +class OtlpProviderCharm(CharmBase): + def __init__(self, framework: ops.Framework): + super().__init__(framework) + self.framework.observe(self.on.update_status, self._publish_endpoints) + + def _publish_endpoints(self, _: ops.EventBase) -> None: + OtlpProvider(self).add_endpoint( + protocol='http', endpoint=f'{socket.getfqdn()}:4318', telemetries=['metrics'] + ).publish() # --- Fixtures --- @@ -115,21 +138,3 @@ def otlp_requirer_ctx() -> testing.Context[OtlpRequirerCharm]: def otlp_provider_ctx() -> testing.Context[OtlpProviderCharm]: meta = {'name': 'otlp-provider', 'provides': {'receive-otlp': {'interface': 'otlp'}}} return testing.Context(OtlpProviderCharm, meta=meta) - - -@pytest.fixture -def otlp_dual_ctx() -> testing.Context[OtlpDualCharm]: - meta = { - 'name': 'otlp-dual', - 'requires': {'send-otlp': {'interface': 'otlp'}}, - 'provides': {'receive-otlp': {'interface': 'otlp'}}, - } - config = { - 'options': { - 'forward_alert_rules': { - 'type': 'boolean', - 'default': True, - }, - }, - } - return testing.Context(OtlpDualCharm, meta=meta, config=config) diff --git a/interfaces/otlp/tests/unit/test_endpoints.py b/interfaces/otlp/tests/unit/test_endpoints.py index 5dea6605..95711a20 100644 --- a/interfaces/otlp/tests/unit/test_endpoints.py +++ b/interfaces/otlp/tests/unit/test_endpoints.py @@ -4,27 +4,24 @@ """Feature: OTLP endpoint handling.""" import json -from typing import Any, cast -from unittest.mock import patch +from collections.abc import Sequence +from typing import Any, Final, Literal, cast import ops import pytest from ops import testing from ops.testing import Relation, State -from charmlibs.interfaces.otlp._otlp import OtlpProviderAppData, _OtlpEndpoint +from charmlibs.interfaces.otlp._otlp import DEFAULT_PROVIDER_RELATION_NAME as RECEIVE +from charmlibs.interfaces.otlp._otlp import DEFAULT_REQUIRER_RELATION_NAME as SEND +from charmlibs.interfaces.otlp._otlp import OtlpRequirer, _OtlpEndpoint, _OtlpProviderAppData +from conftest import ALL_PROTOCOLS, ALL_TELEMETRIES -ALL_PROTOCOLS = ['grpc', 'http'] -ALL_TELEMETRIES = ['logs', 'metrics', 'traces'] -EMPTY_REQUIRER = { - 'rules': json.dumps({'logql': {}, 'promql': {}}), - 'metadata': json.dumps({}), -} +PROTOCOLS: Final[list[Literal['http', 'grpc']]] = ['http', 'grpc'] +TELEMETRIES: Final[list[Literal['metrics', 'logs']]] = ['metrics', 'logs'] -RECEIVE_OTLP = Relation('receive-otlp', remote_app_data=EMPTY_REQUIRER) - -def test_new_endpoint_key_is_ignored_by_databag_model() -> None: +def test_new_endpoint_key_is_ignored_by_databag_model(): # GIVEN the provider offers a new endpoint type (protocol or telemetry) # * the requirer does not support this new endpoint type endpoint = { @@ -36,7 +33,7 @@ def test_new_endpoint_key_is_ignored_by_databag_model() -> None: # WHEN validating the provider databag model, which the requirer uses to access endpoints # THEN the validation succeeds - provider_databag: OtlpProviderAppData = OtlpProviderAppData.model_validate({ + provider_databag: _OtlpProviderAppData = _OtlpProviderAppData.model_validate({ 'endpoints': [endpoint] }) assert provider_databag @@ -115,22 +112,18 @@ def test_send_otlp_invalid_databag( ): # GIVEN a remote app provides an _OtlpEndpoint # WHEN they are related over the "send-otlp" endpoint - provider = Relation('send-otlp', id=123, remote_app_data=provides) + provider = Relation(SEND, id=123, remote_app_data=provides) state = State(relations=[provider], leader=True) with otlp_requirer_ctx(otlp_requirer_ctx.on.update_status(), state=state) as mgr: # WHEN the requirer processes the relation data # * the requirer supports all protocols and telemetries charm_any = cast('Any', mgr.charm) - with ( - patch.object(charm_any.otlp_requirer, '_protocols', new=ALL_PROTOCOLS), - patch.object(charm_any.otlp_requirer, '_telemetries', new=ALL_TELEMETRIES), - ): - # THEN the requirer does not raise an error - # * the returned endpoint does not include new protocols or telemetries - assert mgr.run() - result = charm_any.otlp_requirer.endpoints[123] - assert result.model_dump() == otlp_endpoint.model_dump() + # THEN the requirer does not raise an error + # * the returned endpoint does not include new protocols or telemetries + assert mgr.run() + endpoints = OtlpRequirer(charm_any, SEND, ALL_PROTOCOLS, ALL_TELEMETRIES).endpoints + assert endpoints[123].model_dump() == otlp_endpoint.model_dump() @pytest.mark.parametrize( @@ -184,8 +177,8 @@ def test_send_otlp_invalid_databag( ) def test_send_otlp_with_varying_requirer_support( otlp_requirer_ctx: testing.Context[ops.CharmBase], - protocols: list[str], - telemetries: list[str], + protocols: Sequence[Literal['http', 'grpc']], + telemetries: Sequence[Literal['logs', 'metrics', 'traces']], expected: dict[int, _OtlpEndpoint], ): # GIVEN a remote app provides multiple _OtlpEndpoints @@ -214,29 +207,14 @@ def test_send_otlp_with_varying_requirer_support( } # WHEN they are related over the "send-otlp" endpoint - provider_0 = Relation( - 'send-otlp', - id=123, - remote_app_data=remote_app_data_1, - ) - provider_1 = Relation( - 'send-otlp', - id=456, - remote_app_data=remote_app_data_2, - ) - state = State( - relations=[provider_0, provider_1], - leader=True, - ) + provider_0 = Relation(SEND, id=123, remote_app_data=remote_app_data_1) + provider_1 = Relation(SEND, id=456, remote_app_data=remote_app_data_2) + state = State(relations=[provider_0, provider_1], leader=True) # AND WHEN the requirer has varying support for OTLP protocols and telemetries with otlp_requirer_ctx(otlp_requirer_ctx.on.update_status(), state=state) as mgr: charm_any = cast('Any', mgr.charm) - with ( - patch.object(charm_any.otlp_requirer, '_protocols', new=protocols), - patch.object(charm_any.otlp_requirer, '_telemetries', new=telemetries), - ): - remote_endpoints = charm_any.otlp_requirer.endpoints + remote_endpoints = OtlpRequirer(charm_any, SEND, protocols, telemetries).endpoints # THEN the returned endpoints are filtered accordingly assert {k: v.model_dump() for k, v in remote_endpoints.items()} == { @@ -284,25 +262,14 @@ def test_send_otlp(otlp_requirer_ctx: testing.Context[ops.CharmBase]): } # WHEN they are related over the "send-otlp" endpoint - provider_1 = Relation( - 'send-otlp', - id=123, - remote_app_data=remote_app_data_1, - ) - provider_2 = Relation( - 'send-otlp', - id=456, - remote_app_data=remote_app_data_2, - ) - state = State( - relations=[provider_1, provider_2], - leader=True, - ) + provider_1 = Relation(SEND, id=123, remote_app_data=remote_app_data_1) + provider_2 = Relation(SEND, id=456, remote_app_data=remote_app_data_2) + state = State(relations=[provider_1, provider_2], leader=True) # AND WHEN otelcol supports a subset of OTLP protocols and telemetries with otlp_requirer_ctx(otlp_requirer_ctx.on.update_status(), state=state) as mgr: charm_any = cast('Any', mgr.charm) - remote_endpoints = charm_any.otlp_requirer.endpoints + remote_endpoints = OtlpRequirer(charm_any, SEND, PROTOCOLS, TELEMETRIES).endpoints # THEN the returned endpoints are filtered accordingly assert {k: v.model_dump() for k, v in remote_endpoints.items()} == { @@ -312,10 +279,14 @@ def test_send_otlp(otlp_requirer_ctx: testing.Context[ops.CharmBase]): def test_receive_otlp(otlp_provider_ctx: testing.Context[ops.CharmBase]): # GIVEN a receive-otlp relation - state = State( - leader=True, - relations=[RECEIVE_OTLP], + receiver = Relation( + RECEIVE, + remote_app_data={ + 'rules': json.dumps({'logql': {}, 'promql': {}}), + 'metadata': '{}', + }, ) + state = State(leader=True, relations=[receiver]) # AND WHEN any event executes the reconciler state_out = otlp_provider_ctx.run(otlp_provider_ctx.on.update_status(), state=state) @@ -333,7 +304,7 @@ def test_receive_otlp(otlp_provider_ctx: testing.Context[ops.CharmBase]): } assert (actual_endpoints := json.loads(local_app_data.get('endpoints', '[]'))) assert ( - OtlpProviderAppData.model_validate({'endpoints': actual_endpoints}).model_dump() + _OtlpProviderAppData.model_validate({'endpoints': actual_endpoints}).model_dump() == expected_endpoints ) @@ -393,10 +364,11 @@ def test_favor_modern_endpoints( # GIVEN a list of endpoints state = State(leader=True) with otlp_requirer_ctx(otlp_requirer_ctx.on.update_status(), state=state) as mgr: - charm_any = cast('Any', mgr.charm) - # WHEN the requirer selects an endpoint - result = charm_any.otlp_requirer._favor_modern_endpoints(endpoints) + charm_any = cast('Any', mgr.charm) + result = OtlpRequirer(charm_any, SEND, PROTOCOLS, TELEMETRIES)._favor_modern_endpoints( + endpoints + ) # THEN the most modern one is chosen assert result.protocol == expected_protocol diff --git a/interfaces/otlp/tests/unit/test_rules.py b/interfaces/otlp/tests/unit/test_rules.py index b8e19a94..60199e5d 100644 --- a/interfaces/otlp/tests/unit/test_rules.py +++ b/interfaces/otlp/tests/unit/test_rules.py @@ -1,10 +1,10 @@ # Copyright 2026 Canonical Ltd. # See LICENSE file for licensing details. -"""Feature: Rules aggregation and forwarding.""" +"""Feature: Rules aggregation and labeling.""" import json -from typing import Any +from typing import Any, cast import ops import pytest @@ -12,67 +12,17 @@ from ops import testing from ops.testing import Model, Relation, State -from charmlibs.interfaces.otlp._otlp import OtlpRequirerAppData, _RulesModel - -MODEL = Model('otelcol', uuid='f4d59020-c8e7-4053-8044-a2c1e5591c7f') -OTELCOL_LABELS = { - 'juju_model': 'otelcol', - 'juju_model_uuid': 'f4d59020-c8e7-4053-8044-a2c1e5591c7f', - 'juju_application': 'opentelemetry-collector-k8s', - 'juju_charm': 'opentelemetry-collector-k8s', -} -LOGQL_ALERT = { - 'name': 'otelcol_f4d59020_charm_x_foo_alerts', - 'rules': [ - { - 'alert': 'HighLogVolume', - 'expr': 'count_over_time({job=~".+"}[30s]) > 100', - 'labels': {'severity': 'high'}, - }, - ], -} -LOGQL_RECORD = { - 'name': 'otelcol_f4d59020_charm_x_foobar_alerts', - 'rules': [ - { - 'record': 'log:error_rate:rate5m', - 'expr': 'sum by (service) (rate({job=~".+"} | json | level="error" [5m]))', - 'labels': {'severity': 'high'}, - } - ], -} -PROMQL_ALERT = { - 'name': 'otelcol_f4d59020_charm_x_bar_alerts', - 'rules': [ - { - 'alert': 'Workload Missing', - 'expr': 'up{job=~".+"} == 0', - 'for': '0m', - 'labels': {'severity': 'critical'}, - }, - ], -} -PROMQL_RECORD = { - 'name': 'otelcol_f4d59020_charm_x_barfoo_alerts', - 'rules': [ - { - 'record': 'code:prometheus_http_requests_total:sum', - 'expr': 'sum by (code) (prometheus_http_requests_total{job=~".+"})', - 'labels': {'severity': 'high'}, - } - ], -} -ALL_RULES = { - 'logql': {'groups': [LOGQL_ALERT, LOGQL_RECORD]}, - 'promql': {'groups': [PROMQL_ALERT, PROMQL_RECORD]}, -} -METADATA = { - 'model': 'otelcol', - 'model_uuid': 'f4d59020-c8e7-4053-8044-a2c1e5591c7f', - 'application': 'opentelemetry-collector-k8s', - 'charm': 'opentelemetry-collector-k8s', - 'unit': 'opentelemetry-collector-k8s/0', -} +from charmlibs.interfaces.otlp._otlp import DEFAULT_PROVIDER_RELATION_NAME as RECEIVE +from charmlibs.interfaces.otlp._otlp import DEFAULT_REQUIRER_RELATION_NAME as SEND +from charmlibs.interfaces.otlp._otlp import OtlpProvider, _OtlpRequirerAppData, _RulesModel +from conftest import ( + SINGLE_LOGQL_ALERT, + SINGLE_LOGQL_RECORD, + SINGLE_PROMQL_ALERT, + SINGLE_PROMQL_RECORD, +) + +MODEL = Model('foo-model', uuid='f4d59020-c8e7-4053-8044-a2c1e5591c7f') def _decompress(rules: str | None) -> dict[str, Any]: @@ -81,14 +31,14 @@ def _decompress(rules: str | None) -> dict[str, Any]: return json.loads(LZMABase64.decompress(rules)) -def test_new_rule_is_ignored_by_databag_model() -> None: +def test_new_rule_is_ignored_by_databag_model(): # GIVEN the requirer offers a new rule type # * the provider does not support this new rule type # WHEN validating the requirer databag model, which the provider uses to access rules # THEN the validation succeeds - requirer_databag = OtlpRequirerAppData.model_validate({ + requirer_databag = _OtlpRequirerAppData.model_validate({ 'rules': {'promql': {}, 'new_rule': {}}, - 'metadata': METADATA, + 'metadata': {}, }) assert requirer_databag assert isinstance(requirer_databag.rules, _RulesModel) @@ -96,35 +46,24 @@ def test_new_rule_is_ignored_by_databag_model() -> None: assert 'new_rule' not in requirer_databag.rules.model_dump() -def test_missing_rule_type_defaults() -> None: - # GIVEN the requirer offers a new rule type - # * the provider does not support this new rule type - # WHEN validating the requirer databag model, which the provider uses to access rules +def test_missing_rule_type_defaults(): + # GIVEN no rules or metadata is provided + # WHEN validating the requirer databag model # THEN the validation succeeds - requirer_databag = OtlpRequirerAppData.model_validate({'rules': {}, 'metadata': METADATA}) + requirer_databag = _OtlpRequirerAppData.model_validate({'rules': {}, 'metadata': {}}) assert requirer_databag assert isinstance(requirer_databag.rules, _RulesModel) - # AND the new rule type is ignored - assert 'promql' in requirer_databag.rules.model_dump() - assert 'logql' in requirer_databag.rules.model_dump() + # AND the rule model is created + assert requirer_databag.rules.model_dump().keys() == _RulesModel.model_fields.keys() -def test_rules_compression(otlp_dual_ctx: testing.Context[ops.CharmBase]) -> None: - # GIVEN receive-otlp and send-otlp relations - databag: dict[str, str] = { - 'rules': json.dumps(ALL_RULES, sort_keys=True), - 'metadata': json.dumps(METADATA), - } - receiver = Relation('receive-otlp', remote_app_data=databag) - sender = Relation('send-otlp', remote_app_data={'endpoints': '[]'}) - state = State(relations=[receiver, sender], leader=True, model=MODEL) +def test_rules_compression(otlp_requirer_ctx: testing.Context[ops.CharmBase]): + # GIVEN a send-otlp relation + state = State(relations=[Relation(SEND)], leader=True) # WHEN any event executes the reconciler - state_out = otlp_dual_ctx.run(otlp_dual_ctx.on.update_status(), state=state) - + state_out = otlp_requirer_ctx.run(otlp_requirer_ctx.on.update_status(), state=state) for relation in list(state_out.relations): - if relation.endpoint != 'send-otlp': - continue rules = relation.local_app_data.get('rules', None) assert rules is not None @@ -134,86 +73,107 @@ def test_rules_compression(otlp_dual_ctx: testing.Context[ops.CharmBase]) -> Non decompressed = _decompress(json.loads(rules)) assert decompressed assert isinstance(decompressed, dict) - assert set(ALL_RULES.keys()).issubset(decompressed.keys()) + assert set(_RulesModel.model_fields.keys()).issubset(decompressed.keys()) -@pytest.mark.parametrize( - 'forwarding_enabled, rules, expected_group_counts', - [ - # format , databag_groups, generic_groups, total - # logql , (2) , (0) , (2) - # promql , (2) , (1) , (3) - ( - True, - { - 'logql': {'groups': [LOGQL_ALERT, LOGQL_RECORD]}, - 'promql': {'groups': [PROMQL_ALERT, PROMQL_RECORD]}, - }, - {'logql': 2, 'promql': 3}, - ), - # format , databag_groups, generic_groups, total - # logql , (0) , (0) , (0) - # promql , (2) , (1) , (3) - ( - True, - {'logql': {}, 'promql': {'groups': [PROMQL_ALERT, PROMQL_RECORD]}}, - {'logql': 0, 'promql': 3}, - ), - # format , databag_groups, generic_groups, total - # logql , (2) , (0) , (2) - # promql , (0) , (1) , (1) - ( - True, - {'logql': {'groups': [LOGQL_ALERT, LOGQL_RECORD]}, 'promql': {}}, - {'logql': 2, 'promql': 1}, - ), - ], -) -@pytest.mark.parametrize( - 'metadata', - [METADATA, {}], - ids=['with_metadata', 'without_metadata'], -) -def test_forwarding_otlp_rule_counts( - otlp_dual_ctx: testing.Context[ops.CharmBase], - forwarding_enabled: bool, - rules: dict[str, Any], - expected_group_counts: dict[str, int], - metadata: dict[str, Any], -) -> None: - # GIVEN forwarding of rules is enabled - # * a receive-otlp with rules in the databag - # * two send-otlp relations - databag = {'rules': json.dumps(rules), 'metadata': json.dumps(metadata)} - receiver = Relation('receive-otlp', remote_app_data=databag) - sender_1 = Relation('send-otlp', remote_app_data={'endpoints': '[]'}) - sender_2 = Relation('send-otlp', remote_app_data={'endpoints': '[]'}) - state = State( - relations=[receiver, sender_1, sender_2], - leader=True, - model=MODEL, - config={'forward_alert_rules': forwarding_enabled}, - ) +def test_generic_rule_injection(otlp_requirer_ctx: testing.Context[ops.CharmBase]): + # GIVEN a send-otlp relation + state = State(relations=[Relation(SEND)], leader=True, model=MODEL) # WHEN any event executes the reconciler - state_out = otlp_dual_ctx.run(otlp_dual_ctx.on.update_status(), state=state) - + state_out = otlp_requirer_ctx.run(otlp_requirer_ctx.on.update_status(), state=state) for relation in list(state_out.relations): - if relation.endpoint != 'send-otlp': - continue - + # AND the rules in the databag are decompressed decompressed = _decompress(relation.local_app_data.get('rules')) assert decompressed - requirer_databag: OtlpRequirerAppData = OtlpRequirerAppData.model_validate({ - 'rules': decompressed, - 'metadata': {}, - }) - - # THEN all expected rules exist in the databag - # * databag_groups are included/forwarded - assert ( - len(requirer_databag.rules.logql.get('groups', [])) == expected_group_counts['logql'] - ) - assert ( - len(requirer_databag.rules.promql.get('groups', [])) == expected_group_counts['promql'] - ) + logql_groups = decompressed.get('logql', {}).get('groups', []) + promql_groups = decompressed.get('promql', {}).get('groups', []) + assert logql_groups + assert promql_groups + + # THEN the generic promql rule is in the databag + assert any('AggregatorHostHealth' in g.get('name') for g in promql_groups) + + +def test_metadata(otlp_requirer_ctx: testing.Context[ops.CharmBase]): + # GIVEN a send-otlp relation + state = State(relations=[Relation(SEND)], leader=True, model=MODEL) + + # WHEN any event executes the reconciler + state_out = otlp_requirer_ctx.run(otlp_requirer_ctx.on.update_status(), state=state) + for relation in list(state_out.relations): + # THEN the requirer adds its own metadata to the databag + assert json.loads(relation.local_app_data['metadata']) == { + 'model': 'foo-model', + 'model_uuid': 'f4d59020-c8e7-4053-8044-a2c1e5591c7f', + 'application': 'otlp-requirer', + 'unit': 'otlp-requirer/0', + 'charm_name': 'otlp-requirer', + } + + +@pytest.mark.parametrize( + 'metadata', + [ + {}, + { + 'model': 'foo-model', + 'model_uuid': 'f4d59020-c8e7-4053-8044-a2c1e5591c7f', + 'application': 'otlp-requirer', + 'charm_name': 'otlp-requirer', + 'unit': 'otlp-requirer/0', + }, + ], +) +def test_provider_rules( + otlp_provider_ctx: testing.Context[ops.CharmBase], metadata: dict[str, Any] +): + # GIVEN a requirer offers unlabeled rules (of various types) in the databag + rules = { + 'logql': { + 'groups': [ + {'name': 'test_logql_alert', 'rules': [SINGLE_LOGQL_ALERT]}, + {'name': 'test_logql_record', 'rules': [SINGLE_LOGQL_RECORD]}, + ] + }, + 'promql': { + 'groups': [ + {'name': 'test_promql_alert', 'rules': [SINGLE_PROMQL_ALERT]}, + {'name': 'test_promql_record', 'rules': [SINGLE_PROMQL_RECORD]}, + ] + }, + } + receiver = Relation( + RECEIVE, remote_app_data={'rules': json.dumps(rules), 'metadata': json.dumps(metadata)} + ) + state = State(leader=True, relations=[receiver], model=MODEL) + with otlp_provider_ctx(otlp_provider_ctx.on.update_status(), state=state) as mgr: + # WHEN the provider aggregates the rules from the databag + charm_any = cast('Any', mgr.charm) + logql = OtlpProvider(charm_any, RECEIVE).rules('logql') + promql = OtlpProvider(charm_any, RECEIVE).rules('promql') + assert logql + assert promql + for result in [logql, promql]: + app = metadata['application'] if metadata else 'otlp-provider' + charm = metadata['charm_name'] if metadata else 'otlp-provider' + + # THEN the identifier is present + identifier = 'foo-model_f4d59020_' + app + assert identifier in result + groups = result[identifier].get('groups', []) + assert groups + for group in groups: + for rule in group.get('rules', []): + # AND the rules are labeled with the provider's topology + assert rule['labels']['juju_model'] == 'foo-model' + assert ( + rule['labels']['juju_model_uuid'] == 'f4d59020-c8e7-4053-8044-a2c1e5591c7f' + ) + assert rule['labels']['juju_application'] == app + assert rule['labels']['juju_charm'] == charm + + # AND the expressions are labeled + assert 'juju_model="foo-model"' in rule['expr'] + assert 'juju_model_uuid="f4d59020-c8e7-4053-8044-a2c1e5591c7f"' in rule['expr'] + assert f'juju_application="{app}"' in rule['expr']