Skip to content

Commit

Permalink
charm logging lib (#392)
Browse files Browse the repository at this point in the history
* charm logging POC

* test for logging

* progress

* tls working

* grafana source works with internal url

* fmt

* static fix

* fmt

* fixed itest

* rolled back grafana datasource fix

* pr comments

* tested self-logging

* tls logging

* cacert attr name

* enabled scenarios

* lint

* older python support

* pr comments

* guard container connectivity in logging_endpoints call

* lint

* test fix attempt

* don't raise on error

* pulled lokihandler out

* removed comment

* lint

* pr comments

* fetch-lib

* env cleanup if charm-logging-enable envvar was unset

* pr comments

* lint

* raise early instead of proceeding with logging off

* last review comments
  • Loading branch information
PietroPasotti authored Jun 18, 2024
1 parent dee2a12 commit cc26ade
Show file tree
Hide file tree
Showing 9 changed files with 527 additions and 23 deletions.
357 changes: 357 additions & 0 deletions lib/charms/loki_k8s/v0/charm_logging.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,357 @@
#!/usr/bin/env python3
# Copyright 2022 Canonical Ltd.
# See LICENSE file for licensing details.

"""This charm library contains utilities to automatically forward your charm logs to a loki-push-api endpoint.
(yes! charm code, not workload code!)
If your charm isn't already related to Loki using any of the
consumers/forwarders from the ``loki_push_api`` library, you need to:
charmcraft fetch-lib charms.loki_k8s.v1.loki_push_api
and add the logging consumer that matches your use case.
See https://charmhub.io/loki-k8s/libraries/loki_push_apihttps://charmhub.io/loki-k8s/libraries/loki_push_api
for more information.
Once your charm is related to, for example, COS' Loki charm (or a Grafana Agent),
you will be able to inspect in real time from the Grafana dashboard the logs emitted by your charm.
## Labels
The library will inject the following labels into the records sent to Loki:
- ``model``: name of the juju model this charm is deployed to
- ``model_uuid``: uuid of the model
- ``application``: juju application name (such as 'mycharm')
- ``unit``: unit name (such as 'mycharm/0')
- ``charm_name``: name of the charm (whatever is in metadata.yaml) under 'name'.
- ``juju_hook_name``: name of the juju event being processed
` ``service_name``: name of the service this charm represents.
Defaults to app name, but can be configured by the user.
## Usage
To start using this library, you need to do two things:
1) decorate your charm class with
@log_charm(loki_push_api_endpoint="my_logging_endpoints")
2) add to your charm a "my_logging_endpoint" (you can name this attribute whatever you like) **property**
that returns an http/https endpoint url. If you are using the `LokiPushApiConsumer` as
`self.logging = LokiPushApiConsumer(self, ...)`, the implementation could be:
@property
def my_logging_endpoints(self) -> List[str]:
'''Loki push API endpoints for charm logging.'''
# this will return an empty list if there is no relation or there is no data yet in the relation
return ["http://loki-0.loki.svc.cluster.local:3100"]
The ``log_charm`` decorator will take these endpoints and set up the root logger (as in python's
logging module root logger) to forward all logs to these loki endpoints.
## TLS support
If your charm integrates with a tls provider which is also trusted by the logs receiver, you can
configure TLS by passing a ``server_cert`` parameter to the decorator.
If you're not using the same CA as the loki-push-api endpoint you are sending logs to,
you'll need to implement a cert-transfer relation to obtain the CA certificate from the same
CA that Loki is using.
```
@log_charm(loki_push_api_endpoint="my_logging_endpoint", server_cert="my_server_cert")
class MyCharm(...):
...
@property
def my_server_cert(self) -> Optional[str]:
'''Absolute path to a server crt if TLS is enabled.'''
if self.tls_is_enabled():
return "/path/to/my/server_cert.crt"
```
"""
import functools
import logging
import os
from contextlib import contextmanager
from pathlib import Path
from typing import (
Callable,
Optional,
Sequence,
Type,
TypeVar,
Union,
)

from cosl import JujuTopology
from cosl.loki_logger import LokiHandler # pyright:ignore[reportMissingImports]
from ops.charm import CharmBase
from ops.framework import Framework

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

# 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 = 1

PYDEPS = ["cosl"]

logger = logging.getLogger("charm_logging")
_EndpointGetterType = Union[Callable[[CharmBase], Optional[Sequence[str]]], property]
_CertGetterType = Union[Callable[[CharmBase], Optional[str]], property]
CHARM_LOGGING_ENABLED = "CHARM_LOGGING_ENABLED"


def is_enabled() -> bool:
"""Whether charm logging is enabled.
We assume it is enabled, unless the envvar CHARM_LOGGING_ENABLED is set to `0`
(or anything except `1`).
"""
return os.getenv(CHARM_LOGGING_ENABLED, "1") == "1"


class CharmLoggingError(Exception):
"""Base class for all exceptions raised by this module."""


class InvalidEndpointError(CharmLoggingError):
"""Raised if an endpoint is invalid."""


class InvalidEndpointsError(CharmLoggingError):
"""Raised if an endpoint is invalid."""


@contextmanager
def charm_logging_disabled():
"""Contextmanager to temporarily disable charm logging.
For usage in tests.
"""
previous = os.getenv(CHARM_LOGGING_ENABLED)
os.environ[CHARM_LOGGING_ENABLED] = "0"

yield

if previous is None:
os.environ.pop(CHARM_LOGGING_ENABLED)
else:
os.environ[CHARM_LOGGING_ENABLED] = previous


_C = TypeVar("_C", bound=Type[CharmBase])
_T = TypeVar("_T", bound=type)
_F = TypeVar("_F", bound=Type[Callable])


def _get_logging_endpoints(
logging_endpoints_getter: _EndpointGetterType, self: CharmBase, charm: Type[CharmBase]
):
logging_endpoints: Optional[Sequence[str]]

if isinstance(logging_endpoints_getter, property):
logging_endpoints = logging_endpoints_getter.__get__(self)
else: # method or callable
logging_endpoints = logging_endpoints_getter(self)

if logging_endpoints is None:
logger.debug(
f"Charm logging disabled. {charm.__name__}.{logging_endpoints_getter} returned None."
)
return None

errors = []
sanitized_logging_endponts = []
if isinstance(logging_endpoints, str):
errors.append("invalid return value: expected Iterable[str], got str")
else:
for endpoint in logging_endpoints:
if isinstance(endpoint, str):
sanitized_logging_endponts.append(endpoint)
else:
errors.append(f"invalid endpoint: expected string, got {endpoint!r}")

if errors:
raise InvalidEndpointsError(
f"{charm}.{logging_endpoints_getter} should return an iterable of Loki push-api "
"(-compatible) endpoints (strings); "
f"ERRORS: {errors}"
)

return sanitized_logging_endponts


def _get_server_cert(
server_cert_getter: _CertGetterType, self: CharmBase, charm: Type[CharmBase]
) -> Optional[str]:
if isinstance(server_cert_getter, property):
server_cert = server_cert_getter.__get__(self)
else: # method or callable
server_cert = server_cert_getter(self)

# we're assuming that the ca cert that signed this unit is the same that has signed loki's
if server_cert is None:
logger.debug(f"{charm.__name__}.{server_cert_getter} returned None.")
logger.warning(
"Charm logs are being sent over insecure http because a ca cert is "
"not provided to the charm_logging module."
)
return None

if not isinstance(server_cert, str) and not isinstance(server_cert, Path):
raise ValueError(
f"{charm}.{server_cert_getter} should return a valid path to a tls cert file (string | Path)); "
f"got a {type(server_cert)!r} instead."
)

sc_path = Path(server_cert).absolute()
if not sc_path.exists():
raise RuntimeError(
f"{charm}.{server_cert_getter} returned bad path {server_cert!r}: " f"file not found."
)

return str(sc_path)


def _setup_root_logger_initializer(
charm: Type[CharmBase],
logging_endpoints_getter: _EndpointGetterType,
server_cert_getter: Optional[_CertGetterType],
service_name: Optional[str] = None,
):
"""Patch the charm's initializer and inject a call to set up root logging."""
original_init = charm.__init__

@functools.wraps(original_init)
def wrap_init(self: CharmBase, framework: Framework, *args, **kwargs):
original_init(self, framework, *args, **kwargs)

if not is_enabled():
logger.debug("Charm logging DISABLED by env: skipping root logger initialization")
return

logging_endpoints = _get_logging_endpoints(logging_endpoints_getter, self, charm)

if not logging_endpoints:
return

juju_topology = JujuTopology.from_charm(self)
labels = {
**juju_topology.as_dict(),
"service_name": service_name or self.app.name,
"juju_hook_name": os.getenv("JUJU_HOOK_NAME", ""),
}
server_cert: Optional[Union[str, Path]] = (
_get_server_cert(server_cert_getter, self, charm) if server_cert_getter else None
)

root_logger = logging.getLogger()

for url in logging_endpoints:
handler = LokiHandler(
url=url,
labels=labels,
cert=str(server_cert) if server_cert else None,
)
root_logger.addHandler(handler)

logger.debug("Initialized LokiHandler and set up root logging for charm code.")
return

charm.__init__ = wrap_init


def log_charm(
logging_endpoints: str,
server_cert: Optional[str] = None,
service_name: Optional[str] = None,
):
"""Set up the root logger to forward any charm logs to one or more Loki push API endpoints.
Usage:
>>> from charms.loki_k8s.v0.charm_logging import log_charm
>>> from charms.loki_k8s.v1.loki_push_api import LokiPushApiConsumer
>>> from ops import CharmBase
>>>
>>> @log_charm(
>>> logging_endpoints="loki_push_api_urls",
>>> )
>>> class MyCharm(CharmBase):
>>>
>>> def __init__(self, framework: Framework):
>>> ...
>>> self.logging = LokiPushApiConsumer(self, ...)
>>>
>>> @property
>>> def loki_push_api_urls(self) -> Optional[List[str]]:
>>> return [endpoint['url'] for endpoint in self.logging.loki_endpoints]
>>>
:param server_cert: method or property on the charm type that returns an
optional absolute path to a tls certificate to be used when sending traces to a remote server.
If it returns None, an _insecure_ connection will be used.
:param logging_endpoints: name of a property on the charm type that returns a sequence
of (fully resolvable) Loki push API urls. If None, charm logging will be effectively disabled.
Else, the root logger will be set up to forward all logs to those endpoints.
:param service_name: service name tag to attach to all logs generated by this charm.
Defaults to the juju application name this charm is deployed under.
"""

def _decorator(charm_type: Type[CharmBase]):
"""Autoinstrument the wrapped charmbase type."""
_autoinstrument(
charm_type,
logging_endpoints_getter=getattr(charm_type, logging_endpoints),
server_cert_getter=getattr(charm_type, server_cert) if server_cert else None,
service_name=service_name,
)
return charm_type

return _decorator


def _autoinstrument(
charm_type: Type[CharmBase],
logging_endpoints_getter: _EndpointGetterType,
server_cert_getter: Optional[_CertGetterType] = None,
service_name: Optional[str] = None,
) -> Type[CharmBase]:
"""Set up logging on this charm class.
Use this function to setup automatic log forwarding for all logs emitted throughout executions of
this charm.
Usage:
>>> from charms.loki_k8s.v0.charm_logging import _autoinstrument
>>> from ops.main import main
>>> _autoinstrument(
>>> MyCharm,
>>> logging_endpoints_getter=MyCharm.get_loki_endpoints,
>>> service_name="MyCharm",
>>> )
>>> main(MyCharm)
:param charm_type: the CharmBase subclass to autoinstrument.
:param server_cert_getter: method or property on the charm type that returns an
optional absolute path to a tls certificate to be used when sending traces to a remote server.
If it returns None, an _insecure_ connection will be used.
:param logging_endpoints_getter: name of a property on the charm type that returns a sequence
of (fully resolvable) Loki push API urls. If None, charm logging will be effectively disabled.
Else, the root logger will be set up to forward all logs to those endpoints.
:param service_name: service name tag to attach to all logs generated by this charm.
Defaults to the juju application name this charm is deployed under.
"""
logger.info(f"instrumenting {charm_type}")
_setup_root_logger_initializer(
charm_type,
logging_endpoints_getter,
server_cert_getter=server_cert_getter,
service_name=service_name,
)
return charm_type
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,4 @@ pythonPlatform = "All"
minversion = "6.0"
log_cli_level = "INFO"
asyncio_mode = "auto"
markers = ["setup", "work", "teardown"]
6 changes: 5 additions & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
cosl
cosl>=0.0.12
ops
kubernetes
requests
Expand All @@ -12,3 +12,7 @@ lightkube-models
# Cryptography
# Deps: tls_certificates
cryptography

# deps: tracing, charm_tracing
pydantic
opentelemetry-exporter-otlp-proto-http==1.21.0
Loading

0 comments on commit cc26ade

Please sign in to comment.