Skip to content

Commit d735ae7

Browse files
authored
fix: Use the correct infrastructure to forward logging (#193)
1 parent d6fd0fa commit d735ae7

File tree

3 files changed

+379
-13
lines changed

3 files changed

+379
-13
lines changed
Lines changed: 352 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,352 @@
1+
#!/usr/bin/env python3
2+
# Copyright 2022 Canonical Ltd.
3+
# See LICENSE file for licensing details.
4+
5+
"""This charm library contains utilities to automatically forward your charm logs to a loki-push-api endpoint.
6+
7+
(yes! charm code, not workload code!)
8+
9+
If your charm isn't already related to Loki using any of the
10+
consumers/forwarders from the ``loki_push_api`` library, you need to:
11+
12+
charmcraft fetch-lib charms.loki_k8s.v1.loki_push_api
13+
14+
and add the logging consumer that matches your use case.
15+
See [loki_push_api](https://charmhub.io/loki-k8s/libraries/loki_push_api) for more information.
16+
17+
Once your charm is related to, for example, COS' Loki charm (or a Grafana Agent),
18+
you will be able to inspect in real time from the Grafana dashboard the logs emitted by your charm.
19+
20+
## Labels
21+
22+
The library will inject the following labels into the records sent to Loki:
23+
- ``model``: name of the juju model this charm is deployed to
24+
- ``model_uuid``: uuid of the model
25+
- ``application``: juju application name (such as 'mycharm')
26+
- ``unit``: unit name (such as 'mycharm/0')
27+
- ``charm_name``: name of the charm (whatever is in metadata.yaml) under 'name'.
28+
- ``juju_hook_name``: name of the juju event being processed
29+
` ``service_name``: name of the service this charm represents.
30+
Defaults to app name, but can be configured by the user.
31+
32+
## Usage
33+
34+
To start using this library, you need to do two things:
35+
1) decorate your charm class with
36+
37+
@log_charm(loki_push_api_endpoint="my_logging_endpoints")
38+
39+
2) add to your charm a "my_logging_endpoint" (you can name this attribute whatever you like) **property**
40+
that returns an http/https endpoint url. If you are using the `LokiPushApiConsumer` as
41+
`self.logging = LokiPushApiConsumer(self, ...)`, the implementation could be:
42+
43+
@property
44+
def my_logging_endpoints(self) -> List[str]:
45+
'''Loki push API endpoints for charm logging.'''
46+
# this will return an empty list if there is no relation or there is no data yet in the relation
47+
return ["http://loki-0.loki.svc.cluster.local:3100"]
48+
49+
The ``log_charm`` decorator will take these endpoints and set up the root logger (as in python's
50+
logging module root logger) to forward all logs to these loki endpoints.
51+
52+
## TLS support
53+
If your charm integrates with a tls provider which is also trusted by the logs receiver, you can
54+
configure TLS by passing a ``server_cert`` parameter to the decorator.
55+
56+
If you're not using the same CA as the loki-push-api endpoint you are sending logs to,
57+
you'll need to implement a cert-transfer relation to obtain the CA certificate from the same
58+
CA that Loki is using.
59+
60+
```
61+
@log_charm(loki_push_api_endpoint="my_logging_endpoint", server_cert="my_server_cert")
62+
class MyCharm(...):
63+
...
64+
65+
@property
66+
def my_server_cert(self) -> Optional[str]:
67+
'''Absolute path to a server crt if TLS is enabled.'''
68+
if self.tls_is_enabled():
69+
return "/path/to/my/server_cert.crt"
70+
```
71+
"""
72+
import functools
73+
import logging
74+
import os
75+
from contextlib import contextmanager
76+
from pathlib import Path
77+
from typing import (
78+
Callable,
79+
Optional,
80+
Sequence,
81+
Type,
82+
TypeVar,
83+
Union,
84+
)
85+
86+
from cosl import JujuTopology
87+
from cosl.loki_logger import LokiHandler # pyright:ignore[reportMissingImports]
88+
from ops.charm import CharmBase
89+
from ops.framework import Framework
90+
91+
# The unique Charmhub library identifier, never change it
92+
LIBID = "52ee6051f4e54aedaa60aa04134d1a6d"
93+
94+
# Increment this major API version when introducing breaking changes
95+
LIBAPI = 0
96+
97+
# Increment this PATCH version before using `charmcraft publish-lib` or reset
98+
# to 0 if you are raising the major API version
99+
LIBPATCH = 3
100+
101+
PYDEPS = ["cosl"]
102+
103+
logger = logging.getLogger("charm_logging")
104+
_EndpointGetterType = Union[Callable[[CharmBase], Optional[Sequence[str]]], property]
105+
_CertGetterType = Union[Callable[[CharmBase], Optional[str]], property]
106+
CHARM_LOGGING_ENABLED = "CHARM_LOGGING_ENABLED"
107+
108+
109+
def is_enabled() -> bool:
110+
"""Whether charm logging is enabled.
111+
112+
We assume it is enabled, unless the envvar CHARM_LOGGING_ENABLED is set to `0`
113+
(or anything except `1`).
114+
"""
115+
return os.getenv(CHARM_LOGGING_ENABLED, "1") == "1"
116+
117+
118+
class CharmLoggingError(Exception):
119+
"""Base class for all exceptions raised by this module."""
120+
121+
122+
class InvalidEndpointError(CharmLoggingError):
123+
"""Raised if an endpoint is invalid."""
124+
125+
126+
class InvalidEndpointsError(CharmLoggingError):
127+
"""Raised if an endpoint is invalid."""
128+
129+
130+
@contextmanager
131+
def charm_logging_disabled():
132+
"""Contextmanager to temporarily disable charm logging.
133+
134+
For usage in tests.
135+
"""
136+
previous = os.getenv(CHARM_LOGGING_ENABLED)
137+
os.environ[CHARM_LOGGING_ENABLED] = "0"
138+
139+
yield
140+
141+
if previous is None:
142+
os.environ.pop(CHARM_LOGGING_ENABLED)
143+
else:
144+
os.environ[CHARM_LOGGING_ENABLED] = previous
145+
146+
147+
_C = TypeVar("_C", bound=Type[CharmBase])
148+
_T = TypeVar("_T", bound=type)
149+
_F = TypeVar("_F", bound=Type[Callable])
150+
151+
152+
def _get_logging_endpoints(
153+
logging_endpoints_getter: _EndpointGetterType, self: CharmBase, charm: Type[CharmBase]
154+
):
155+
logging_endpoints: Optional[Sequence[str]]
156+
157+
if isinstance(logging_endpoints_getter, property):
158+
logging_endpoints = logging_endpoints_getter.__get__(self)
159+
else: # method or callable
160+
logging_endpoints = logging_endpoints_getter(self)
161+
162+
if logging_endpoints is None:
163+
logger.debug(
164+
f"Charm logging disabled. {charm.__name__}.{logging_endpoints_getter} returned None."
165+
)
166+
return None
167+
168+
errors = []
169+
sanitized_logging_endponts = []
170+
if isinstance(logging_endpoints, str):
171+
errors.append("invalid return value: expected Iterable[str], got str")
172+
else:
173+
for endpoint in logging_endpoints:
174+
if isinstance(endpoint, str):
175+
sanitized_logging_endponts.append(endpoint)
176+
else:
177+
errors.append(f"invalid endpoint: expected string, got {endpoint!r}")
178+
179+
if errors:
180+
raise InvalidEndpointsError(
181+
f"{charm}.{logging_endpoints_getter} should return an iterable of Loki push-api "
182+
"(-compatible) endpoints (strings); "
183+
f"ERRORS: {errors}"
184+
)
185+
186+
return sanitized_logging_endponts
187+
188+
189+
def _get_server_cert(
190+
server_cert_getter: _CertGetterType, self: CharmBase, charm: Type[CharmBase]
191+
) -> Optional[str]:
192+
if isinstance(server_cert_getter, property):
193+
server_cert = server_cert_getter.__get__(self)
194+
else: # method or callable
195+
server_cert = server_cert_getter(self)
196+
197+
# we're assuming that the ca cert that signed this unit is the same that has signed loki's
198+
if server_cert is None:
199+
logger.debug(f"{charm.__name__}.{server_cert_getter} returned None: can't use https.")
200+
return None
201+
202+
if not isinstance(server_cert, str) and not isinstance(server_cert, Path):
203+
raise ValueError(
204+
f"{charm}.{server_cert_getter} should return a valid path to a tls cert file (string | Path)); "
205+
f"got a {type(server_cert)!r} instead."
206+
)
207+
208+
sc_path = Path(server_cert).absolute()
209+
if not sc_path.exists():
210+
raise RuntimeError(
211+
f"{charm}.{server_cert_getter} returned bad path {server_cert!r}: " f"file not found."
212+
)
213+
214+
return str(sc_path)
215+
216+
217+
def _setup_root_logger_initializer(
218+
charm: Type[CharmBase],
219+
logging_endpoints_getter: _EndpointGetterType,
220+
server_cert_getter: Optional[_CertGetterType],
221+
service_name: Optional[str] = None,
222+
):
223+
"""Patch the charm's initializer and inject a call to set up root logging."""
224+
original_init = charm.__init__
225+
226+
@functools.wraps(original_init)
227+
def wrap_init(self: CharmBase, framework: Framework, *args, **kwargs):
228+
original_init(self, framework, *args, **kwargs)
229+
230+
if not is_enabled():
231+
logger.debug("Charm logging DISABLED by env: skipping root logger initialization")
232+
return
233+
234+
logging_endpoints = _get_logging_endpoints(logging_endpoints_getter, self, charm)
235+
236+
if not logging_endpoints:
237+
return
238+
239+
juju_topology = JujuTopology.from_charm(self)
240+
labels = {
241+
**juju_topology.as_dict(),
242+
"service_name": service_name or self.app.name,
243+
"juju_hook_name": os.getenv("JUJU_HOOK_NAME", ""),
244+
}
245+
server_cert: Optional[Union[str, Path]] = (
246+
_get_server_cert(server_cert_getter, self, charm) if server_cert_getter else None
247+
)
248+
249+
root_logger = logging.getLogger()
250+
251+
for url in logging_endpoints:
252+
handler = LokiHandler(
253+
url=url,
254+
labels=labels,
255+
cert=str(server_cert) if server_cert else None,
256+
)
257+
root_logger.addHandler(handler)
258+
259+
logger.debug("Initialized LokiHandler and set up root logging for charm code.")
260+
return
261+
262+
charm.__init__ = wrap_init
263+
264+
265+
def log_charm(
266+
logging_endpoints: str,
267+
server_cert: Optional[str] = None,
268+
service_name: Optional[str] = None,
269+
):
270+
"""Set up the root logger to forward any charm logs to one or more Loki push API endpoints.
271+
272+
Usage:
273+
>>> from charms.loki_k8s.v0.charm_logging import log_charm
274+
>>> from charms.loki_k8s.v1.loki_push_api import LokiPushApiConsumer
275+
>>> from ops import CharmBase
276+
>>>
277+
>>> @log_charm(
278+
>>> logging_endpoints="loki_push_api_urls",
279+
>>> )
280+
>>> class MyCharm(CharmBase):
281+
>>>
282+
>>> def __init__(self, framework: Framework):
283+
>>> ...
284+
>>> self.logging = LokiPushApiConsumer(self, ...)
285+
>>>
286+
>>> @property
287+
>>> def loki_push_api_urls(self) -> Optional[List[str]]:
288+
>>> return [endpoint['url'] for endpoint in self.logging.loki_endpoints]
289+
>>>
290+
:param server_cert: method or property on the charm type that returns an
291+
optional absolute path to a tls certificate to be used when sending traces to a remote server.
292+
If it returns None, an _insecure_ connection will be used.
293+
:param logging_endpoints: name of a property on the charm type that returns a sequence
294+
of (fully resolvable) Loki push API urls. If None, charm logging will be effectively disabled.
295+
Else, the root logger will be set up to forward all logs to those endpoints.
296+
:param service_name: service name tag to attach to all logs generated by this charm.
297+
Defaults to the juju application name this charm is deployed under.
298+
"""
299+
300+
def _decorator(charm_type: Type[CharmBase]):
301+
"""Autoinstrument the wrapped charmbase type."""
302+
_autoinstrument(
303+
charm_type,
304+
logging_endpoints_getter=getattr(charm_type, logging_endpoints),
305+
server_cert_getter=getattr(charm_type, server_cert) if server_cert else None,
306+
service_name=service_name,
307+
)
308+
return charm_type
309+
310+
return _decorator
311+
312+
313+
def _autoinstrument(
314+
charm_type: Type[CharmBase],
315+
logging_endpoints_getter: _EndpointGetterType,
316+
server_cert_getter: Optional[_CertGetterType] = None,
317+
service_name: Optional[str] = None,
318+
) -> Type[CharmBase]:
319+
"""Set up logging on this charm class.
320+
321+
Use this function to setup automatic log forwarding for all logs emitted throughout executions of
322+
this charm.
323+
324+
Usage:
325+
326+
>>> from charms.loki_k8s.v0.charm_logging import _autoinstrument
327+
>>> from ops.main import main
328+
>>> _autoinstrument(
329+
>>> MyCharm,
330+
>>> logging_endpoints_getter=MyCharm.get_loki_endpoints,
331+
>>> service_name="MyCharm",
332+
>>> )
333+
>>> main(MyCharm)
334+
335+
:param charm_type: the CharmBase subclass to autoinstrument.
336+
:param server_cert_getter: method or property on the charm type that returns an
337+
optional absolute path to a tls certificate to be used when sending traces to a remote server.
338+
If it returns None, an _insecure_ connection will be used.
339+
:param logging_endpoints_getter: name of a property on the charm type that returns a sequence
340+
of (fully resolvable) Loki push API urls. If None, charm logging will be effectively disabled.
341+
Else, the root logger will be set up to forward all logs to those endpoints.
342+
:param service_name: service name tag to attach to all logs generated by this charm.
343+
Defaults to the juju application name this charm is deployed under.
344+
"""
345+
logger.info(f"instrumenting {charm_type}")
346+
_setup_root_logger_initializer(
347+
charm_type,
348+
logging_endpoints_getter,
349+
server_cert_getter=server_cert_getter,
350+
service_name=service_name,
351+
)
352+
return charm_type

0 commit comments

Comments
 (0)