Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

charm logging lib #392

Merged
merged 39 commits into from
Jun 18, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
2ac9679
charm logging POC
PietroPasotti Apr 29, 2024
63019fd
test for logging
PietroPasotti May 1, 2024
b0d8fff
progress
PietroPasotti May 2, 2024
283a6e1
tls working
PietroPasotti May 2, 2024
883da53
grafana source works with internal url
PietroPasotti May 2, 2024
233fab4
fmt
PietroPasotti May 2, 2024
7e1b768
static fix
PietroPasotti May 2, 2024
585d475
fmt
PietroPasotti May 2, 2024
f25e367
fixed itest
PietroPasotti May 2, 2024
0108e0a
rolled back grafana datasource fix
PietroPasotti May 2, 2024
d9bb7da
pr comments
PietroPasotti May 21, 2024
c89e89b
tested self-logging
PietroPasotti May 28, 2024
c1b2415
merge from main
PietroPasotti May 28, 2024
af0e4c2
tls logging
PietroPasotti May 28, 2024
7dfcfaf
cacert attr name
PietroPasotti May 28, 2024
61babd6
enabled scenarios
PietroPasotti May 28, 2024
704fbcc
lint
PietroPasotti May 28, 2024
17c5dd8
older python support
PietroPasotti May 28, 2024
5955d8a
pr comments
PietroPasotti May 29, 2024
d1be299
guard container connectivity in logging_endpoints call
PietroPasotti May 29, 2024
de62106
lint
PietroPasotti May 29, 2024
62ad62b
test fix attempt
PietroPasotti May 30, 2024
2d1cf51
don't raise on error
PietroPasotti May 30, 2024
dace1da
Merge branch 'main' into charm-logging
PietroPasotti May 31, 2024
85fe05c
pulled lokihandler out
PietroPasotti May 31, 2024
3e44901
removed comment
PietroPasotti May 31, 2024
7607afd
lint
PietroPasotti May 31, 2024
c9f037f
Merge branch 'charm-logging' of github.com:canonical/loki-k8s-operato…
PietroPasotti May 31, 2024
7cdb35e
pr comments
PietroPasotti Jun 7, 2024
4c88456
Merge branch 'main' into charm-logging
PietroPasotti Jun 7, 2024
dcbdd67
fetch-lib
PietroPasotti Jun 7, 2024
18789c2
Merge branch 'charm-logging' of github.com:canonical/loki-k8s-operato…
PietroPasotti Jun 7, 2024
517ab72
env cleanup if charm-logging-enable envvar was unset
PietroPasotti Jun 7, 2024
83e9e36
pr comments
PietroPasotti Jun 7, 2024
70cf96f
lint
PietroPasotti Jun 7, 2024
a8ca269
raise early instead of proceeding with logging off
PietroPasotti Jun 14, 2024
906a1a7
Merge remote-tracking branch 'origin/main' into charm-logging
PietroPasotti Jun 17, 2024
b7d3fa5
last review comments
PietroPasotti Jun 18, 2024
f61bd9e
Merge branch 'main' into charm-logging
PietroPasotti Jun 18, 2024
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
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
PietroPasotti marked this conversation as resolved.
Show resolved Hide resolved
- ``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:
PietroPasotti marked this conversation as resolved.
Show resolved Hide resolved
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")
PietroPasotti marked this conversation as resolved.
Show resolved Hide resolved
_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"
PietroPasotti marked this conversation as resolved.
Show resolved Hide resolved


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.
PietroPasotti marked this conversation as resolved.
Show resolved Hide resolved
"""
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(
PietroPasotti marked this conversation as resolved.
Show resolved Hide resolved
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}"
)
PietroPasotti marked this conversation as resolved.
Show resolved Hide resolved

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.")
Dismissed Show dismissed Hide dismissed
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
)
PietroPasotti marked this conversation as resolved.
Show resolved Hide resolved

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,
PietroPasotti marked this conversation as resolved.
Show resolved Hide resolved
):
"""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(
PietroPasotti marked this conversation as resolved.
Show resolved Hide resolved
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
Loading