|
| 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