Skip to content

Commit

Permalink
loki push api charm logging helper (#423)
Browse files Browse the repository at this point in the history
* loki push api charm logging helper

* vbump

* docstring

* Update lib/charms/loki_k8s/v1/loki_push_api.py

Co-authored-by: Jose C. Massón <[email protected]>

* Update lib/charms/loki_k8s/v1/loki_push_api.py

Co-authored-by: Jose C. Massón <[email protected]>

* lint

* lint, again

* pinned otlp transitive deps as well

* better names for charm tracing vars

* better names for charm logging vars

* fixed testS

---------

Co-authored-by: Jose C. Massón <[email protected]>
  • Loading branch information
PietroPasotti and Abuelodelanada authored Aug 7, 2024
1 parent f3ad63f commit 83c6641
Show file tree
Hide file tree
Showing 5 changed files with 171 additions and 21 deletions.
75 changes: 72 additions & 3 deletions lib/charms/loki_k8s/v1/loki_push_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -480,6 +480,25 @@ def _alert_rules_error(self, event):
Units of consumer charm send their alert rules over app relation data using the `alert_rules`
key.
## Charm logging
The `charms.loki_k8s.v0.charm_logging` library can be used in conjunction with this one to configure python's
logging module to forward all logs to Loki via the loki-push-api interface.
```python
from lib.charms.loki_k8s.v0.charm_logging import log_charm
from lib.charms.loki_k8s.v1.loki_push_api import charm_logging_config, LokiPushApiConsumer
@log_charm(logging_endpoint="my_endpoints", server_cert="cert_path")
class MyCharm(...):
_cert_path = "/path/to/cert/on/charm/container.crt"
def __init__(self, ...):
self.logging = LokiPushApiConsumer(...)
self.my_endpoints, self.cert_path = charm_logging_config(
self.logging, self._cert_path)
```
Do this, and all charm logs will be forwarded to Loki as soon as a relation is formed.
"""

import json
Expand Down Expand Up @@ -577,7 +596,11 @@ def _alert_rules_error(self, event):
GRPC_LISTEN_PORT_START = 9095 # odd start port


class RelationNotFoundError(ValueError):
class LokiPushApiError(Exception):
"""Base class for errors raised by this module."""


class RelationNotFoundError(LokiPushApiError):
"""Raised if there is no relation with the given name."""

def __init__(self, relation_name: str):
Expand All @@ -587,7 +610,7 @@ def __init__(self, relation_name: str):
super().__init__(self.message)


class RelationInterfaceMismatchError(Exception):
class RelationInterfaceMismatchError(LokiPushApiError):
"""Raised if the relation with the given name has a different interface."""

def __init__(
Expand All @@ -607,7 +630,7 @@ def __init__(
super().__init__(self.message)


class RelationRoleMismatchError(Exception):
class RelationRoleMismatchError(LokiPushApiError):
"""Raised if the relation with the given name has a different direction."""

def __init__(
Expand Down Expand Up @@ -2750,3 +2773,49 @@ def _exec(self, cmd) -> str:
result = subprocess.run(cmd, check=True, stdout=subprocess.PIPE)
output = result.stdout.decode("utf-8").strip()
return output


def charm_logging_config(
endpoint_requirer: LokiPushApiConsumer, cert_path: Optional[Union[Path, str]]
) -> Tuple[Optional[List[str]], Optional[str]]:
"""Utility function to determine the charm_logging config you will likely want.
If no endpoint is provided:
disable charm logging.
If https endpoint is provided but cert_path is not found on disk:
disable charm logging.
If https endpoint is provided and cert_path is None:
ERROR
Else:
proceed with charm logging (with or without tls, as appropriate)
Args:
endpoint_requirer: an instance of LokiPushApiConsumer.
cert_path: a path where a cert is stored.
Returns:
A tuple with (optionally) the values of the endpoints and the certificate path.
Raises:
LokiPushApiError: if some endpoint are http and others https.
"""
endpoints = [ep["url"] for ep in endpoint_requirer.loki_endpoints]
if not endpoints:
return None, None

https = tuple(endpoint.startswith("https://") for endpoint in endpoints)

if all(https): # all endpoints are https
if cert_path is None:
raise LokiPushApiError("Cannot send logs to https endpoints without a certificate.")
if not Path(cert_path).exists():
# if endpoints is https BUT we don't have a server_cert yet:
# disable charm logging until we do to prevent tls errors
return None, None
return endpoints, str(cert_path)

if all(not x for x in https): # all endpoints are http
return endpoints, None

# if there's a disagreement, that's very weird:
raise LokiPushApiError("Some endpoints are http, some others are https. That's not good.")
6 changes: 6 additions & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,10 @@ cryptography

# deps: tracing, charm_tracing
pydantic

opentelemetry-api==1.21.0
opentelemetry-exporter-otlp-proto-common==1.21.0
opentelemetry-exporter-otlp-proto-http==1.21.0
opentelemetry-proto==1.21.0
opentelemetry-sdk==1.21.0
opentelemetry-semantic-conventions==0.42b0
24 changes: 10 additions & 14 deletions src/charm.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@
from charms.observability_libs.v1.cert_handler import CertHandler
from charms.prometheus_k8s.v0.prometheus_scrape import MetricsEndpointProvider
from charms.tempo_k8s.v1.charm_tracing import trace_charm
from charms.tempo_k8s.v2.tracing import TracingEndpointRequirer
from charms.tempo_k8s.v2.tracing import TracingEndpointRequirer, charm_tracing_config
from charms.traefik_k8s.v1.ingress_per_unit import IngressPerUnitRequirer
from config_builder import (
CERT_FILE,
Expand Down Expand Up @@ -98,8 +98,8 @@ def to_status(tpl: Tuple[str, str]) -> StatusBase:


@trace_charm(
tracing_endpoint="tracing_endpoint",
server_cert="server_ca_cert_path",
tracing_endpoint="_charm_tracing_endpoint",
server_cert="_charm_tracing_ca_cert",
extra_types=[
GrafanaDashboardProvider,
GrafanaSourceProvider,
Expand All @@ -109,7 +109,7 @@ def to_status(tpl: Tuple[str, str]) -> StatusBase:
MetricsEndpointProvider,
],
)
@log_charm(logging_endpoints="logging_endpoints", server_cert="server_ca_cert_path")
@log_charm(logging_endpoints="_charm_logging_endpoints", server_cert="_charm_logging_ca_cert")
class LokiOperatorCharm(CharmBase):
"""Charm the service."""

Expand Down Expand Up @@ -218,6 +218,9 @@ def __init__(self, *args):

self.catalogue = CatalogueConsumer(charm=self, item=self._catalogue_item)
self.tracing = TracingEndpointRequirer(self, protocols=["otlp_http"])
self._charm_tracing_endpoint, self._charm_tracing_ca_cert = charm_tracing_config(
self.tracing, self._ca_cert_path
)

self.framework.observe(self.on.config_changed, self._on_config_changed)
self.framework.observe(self.on.upgrade_charm, self._on_upgrade_charm)
Expand Down Expand Up @@ -791,23 +794,16 @@ def _loki_version(self) -> Optional[str]:
return result.group(1)

@property
def tracing_endpoint(self) -> Optional[str]:
"""Tempo endpoint for charm tracing."""
if self.tracing.is_ready():
return self.tracing.get_endpoint("otlp_http")
return None

@property
def logging_endpoints(self) -> List[str]:
def _charm_logging_endpoints(self) -> List[str]:
"""Loki endpoint for charm logging."""
container = self._loki_container
if container.can_connect() and container.get_service(self._name).is_running():
scheme = "https" if self.server_ca_cert_path else "http"
scheme = "https" if self._charm_logging_ca_cert else "http"
return [f"{scheme}://localhost:3100" + self._loki_push_api_endpoint]
return []

@property
def server_ca_cert_path(self) -> Optional[str]:
def _charm_logging_ca_cert(self) -> Optional[str]:
"""Server CA certificate path for TLS tracing."""
if self._tls_ready:
return self._ca_cert_path
Expand Down
8 changes: 4 additions & 4 deletions tests/scenario/test_charm_logging.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ def test_no_endpoints_on_loki_not_ready(context, loki_emitter):

with context.manager("update-status", state) as mgr:
charm = mgr.charm
assert charm.logging_endpoints == []
assert charm._charm_logging_endpoints == []
logging.getLogger("foo").debug("bar")

loki_emitter.assert_not_called()
Expand All @@ -48,7 +48,7 @@ def test_endpoints_on_loki_ready(context, loki_emitter):

with context.manager("update-status", state) as mgr:
charm = mgr.charm
assert charm.logging_endpoints == ["http://localhost:3100/loki/api/v1/push"]
assert charm._charm_logging_endpoints == ["http://localhost:3100/loki/api/v1/push"]
logging.getLogger("foo").debug("bar")

loki_emitter.assert_called()
Expand All @@ -60,7 +60,7 @@ def test_endpoints_on_loki_ready(context, loki_emitter):
assert record.name == "foo"


@patch("charm.LokiOperatorCharm.server_ca_cert_path", new_callable=lambda *_: True)
@patch("charm.LokiOperatorCharm._charm_logging_ca_cert", new_callable=lambda *_: True)
def test_endpoints_on_loki_ready_tls(_, context, loki_emitter):
state = scenario.State(
containers=[
Expand All @@ -76,7 +76,7 @@ def test_endpoints_on_loki_ready_tls(_, context, loki_emitter):

with context.manager("update-status", state) as mgr:
charm = mgr.charm
assert charm.logging_endpoints == ["https://localhost:3100/loki/api/v1/push"]
assert charm._charm_logging_endpoints == ["https://localhost:3100/loki/api/v1/push"]
logging.getLogger("foo").debug("bar")

loki_emitter.assert_called()
Expand Down
79 changes: 79 additions & 0 deletions tests/scenario/test_charm_logging_config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
from unittest.mock import MagicMock

import pytest
from charms.loki_k8s.v1.loki_push_api import LokiPushApiError, charm_logging_config


def test_charm_logging_config_http():
# GIVEN endpoints are http
raw_eps = ["http://foo.com", "http://woo.com"]
eps = [{"url": url} for url in raw_eps]

lpa = MagicMock()
lpa.loki_endpoints = eps
# AND we don't have a cert (tls not supported)
endpoints, cert = charm_logging_config(lpa, None)

# enable charm logging (http)
assert endpoints == raw_eps
assert cert is None


def test_charm_logging_config_https_tls_ready(tmp_path):
# GIVEN endpoints are https
raw_eps = ["https://foo.com", "https://woo.com"]
eps = [{"url": url} for url in raw_eps]
cert_path = tmp_path / "foo.crt"
# AND cert file exists
cert_path.write_text("hello cert")

lpa = MagicMock()
lpa.loki_endpoints = eps
endpoints, cert = charm_logging_config(lpa, cert_path)

# enable charm logging (https)
assert endpoints == raw_eps
assert cert == str(cert_path)


def test_charm_logging_config_https_tls_not_ready(tmp_path):
# GIVEN endpoints are https
raw_eps = ["https://foo.com", "https://woo.com"]
eps = [{"url": url} for url in raw_eps]
# BUT cert file does not exist
cert_path = tmp_path / "foo.crt"

lpa = MagicMock()
lpa.loki_endpoints = eps
endpoints, cert = charm_logging_config(lpa, cert_path)

# disable charm logging
assert endpoints is None
assert cert is None


def test_charm_logging_config_https_tls_not_impl(tmp_path):
# GIVEN endpoints are https
raw_eps = ["https://foo.com", "https://woo.com"]
eps = [{"url": url} for url in raw_eps]
# AND we don't even pretend there's a cert

lpa = MagicMock()
lpa.loki_endpoints = eps
with pytest.raises(LokiPushApiError):
charm_logging_config(lpa, None)


def test_charm_logging_config_https_http_mix(tmp_path):
# GIVEN endpoints are a mix of http and https
raw_eps = ["https://foo.com", "http://woo.com"]
eps = [{"url": url} for url in raw_eps]
lpa = MagicMock()
lpa.loki_endpoints = eps

# we get an error whether we pass a cert or not
with pytest.raises(LokiPushApiError):
charm_logging_config(lpa, "/foo/bar.crt")

with pytest.raises(LokiPushApiError):
charm_logging_config(lpa, None)

0 comments on commit 83c6641

Please sign in to comment.