From a40184be1a250a7e3af4f5cfc6dd34398c54df2a Mon Sep 17 00:00:00 2001 From: PietroPasotti Date: Fri, 11 Oct 2024 13:10:26 +0200 Subject: [PATCH] migrated charm tracing libs (#351) * migrated charm tracing libs * pin ops --------- Co-authored-by: Luca Bello Co-authored-by: Luca Bello <36242061+lucabello@users.noreply.github.com> --- .../tempo_coordinator_k8s/v0/charm_tracing.py | 760 ++++++++++++++++++ .../v0}/tracing.py | 65 +- lib/charms/tempo_k8s/v1/charm_tracing.py | 568 ------------- requirements.txt | 3 +- src/charm.py | 4 +- 5 files changed, 802 insertions(+), 598 deletions(-) create mode 100644 lib/charms/tempo_coordinator_k8s/v0/charm_tracing.py rename lib/charms/{tempo_k8s/v2 => tempo_coordinator_k8s/v0}/tracing.py (94%) delete mode 100644 lib/charms/tempo_k8s/v1/charm_tracing.py diff --git a/lib/charms/tempo_coordinator_k8s/v0/charm_tracing.py b/lib/charms/tempo_coordinator_k8s/v0/charm_tracing.py new file mode 100644 index 00000000..1e7ff840 --- /dev/null +++ b/lib/charms/tempo_coordinator_k8s/v0/charm_tracing.py @@ -0,0 +1,760 @@ +#!/usr/bin/env python3 +# Copyright 2022 Canonical Ltd. +# See LICENSE file for licensing details. + +"""This charm library contains utilities to instrument your Charm with opentelemetry tracing data collection. + +(yes! charm code, not workload code!) + +This means that, if your charm is related to, for example, COS' Tempo charm, you will be able to inspect +in real time from the Grafana dashboard the execution flow of your charm. + +# Quickstart +Fetch the following charm libs (and ensure the minimum version/revision numbers are satisfied): + + charmcraft fetch-lib charms.tempo_coordinator_k8s.v0.tracing # >= 1.10 + charmcraft fetch-lib charms.tempo_coordinator_k8s.v0.charm_tracing # >= 2.7 + +Then edit your charm code to include: + +```python +# import the necessary charm libs +from charms.tempo_coordinator_k8s.v0.tracing import TracingEndpointRequirer, charm_tracing_config +from charms.tempo_coordinator_k8s.v0.charm_tracing import charm_tracing + +# decorate your charm class with charm_tracing: +@charm_tracing( + # forward-declare the instance attributes that the instrumentor will look up to obtain the + # tempo endpoint and server certificate + tracing_endpoint="tracing_endpoint", + server_cert="server_cert" +) +class MyCharm(CharmBase): + _path_to_cert = "/path/to/cert.crt" + # path to cert file **in the charm container**. Its presence will be used to determine whether + # the charm is ready to use tls for encrypting charm traces. If your charm does not support tls, + # you can ignore this and pass None to charm_tracing_config. + # If you do support TLS, you'll need to make sure that the server cert is copied to this location + # and kept up to date so the instrumentor can use it. + + def __init__(self, ...): + ... + self.tracing = TracingEndpointRequirer(self, ...) + self.tracing_endpoint, self.server_cert = charm_tracing_config(self.tracing, self._path_to_cert) +``` + +# Detailed usage +To use this library, you need to do two things: +1) decorate your charm class with + +`@trace_charm(tracing_endpoint="my_tracing_endpoint")` + +2) add to your charm a "my_tracing_endpoint" (you can name this attribute whatever you like) +**property**, **method** or **instance attribute** that returns an otlp http/https endpoint url. +If you are using the ``charms.tempo_coordinator_k8s.v0.tracing.TracingEndpointRequirer`` as +``self.tracing = TracingEndpointRequirer(self)``, the implementation could be: + +``` + @property + def my_tracing_endpoint(self) -> Optional[str]: + '''Tempo endpoint for charm tracing''' + if self.tracing.is_ready(): + return self.tracing.get_endpoint("otlp_http") + else: + return None +``` + +At this point your charm will be automatically instrumented so that: +- charm execution starts a trace, containing + - every event as a span (including custom events) + - every charm method call (except dunders) as a span + + +## TLS support +If your charm integrates with a TLS provider which is also trusted by the tracing provider (the Tempo charm), +you can configure ``charm_tracing`` to use TLS by passing a ``server_cert`` parameter to the decorator. + +If your charm is not trusting the same CA as the Tempo endpoint it is sending traces to, +you'll need to implement a cert-transfer relation to obtain the CA certificate from the same +CA that Tempo is using. + +For example: +``` +from charms.tempo_coordinator_k8s.v0.charm_tracing import trace_charm +@trace_charm( + tracing_endpoint="my_tracing_endpoint", + server_cert="_server_cert" +) +class MyCharm(CharmBase): + self._server_cert = "/path/to/server.crt" + ... + + def on_tls_changed(self, e) -> Optional[str]: + # update the server cert on the charm container for charm tracing + Path(self._server_cert).write_text(self.get_server_cert()) + + def on_tls_broken(self, e) -> Optional[str]: + # remove the server cert so charm_tracing won't try to use tls anymore + Path(self._server_cert).unlink() +``` + + +## More fine-grained manual instrumentation +if you wish to add more spans to the trace, you can do so by getting a hold of the tracer like so: +``` +import opentelemetry +... +def get_tracer(self) -> opentelemetry.trace.Tracer: + return opentelemetry.trace.get_tracer(type(self).__name__) +``` + +By default, the tracer is named after the charm type. If you wish to override that, you can pass +a different ``service_name`` argument to ``trace_charm``. + +See the official opentelemetry Python SDK documentation for usage: +https://opentelemetry-python.readthedocs.io/en/latest/ + +## Upgrading from `v0` + +If you are upgrading from `charm_tracing` v0, you need to take the following steps (assuming you already +have the newest version of the library in your charm): +1) If you need the dependency for your tests, add the following dependency to your charm project +(or, if your project had a dependency on `opentelemetry-exporter-otlp-proto-grpc` only because +of `charm_tracing` v0, you can replace it with): + +`opentelemetry-exporter-otlp-proto-http>=1.21.0`. + +2) Update the charm method referenced to from ``@trace`` and ``@trace_charm``, +to return from ``TracingEndpointRequirer.get_endpoint("otlp_http")`` instead of ``grpc_http``. +For example: + +``` + from charms.tempo_coordinator_k8s.v0.charm_tracing import trace_charm + + @trace_charm( + tracing_endpoint="my_tracing_endpoint", + ) + class MyCharm(CharmBase): + + ... + + @property + def my_tracing_endpoint(self) -> Optional[str]: + '''Tempo endpoint for charm tracing''' + if self.tracing.is_ready(): + return self.tracing.otlp_grpc_endpoint() # OLD API, DEPRECATED. + else: + return None +``` + +needs to be replaced with: + +``` + from charms.tempo_coordinator_k8s.v0.charm_tracing import trace_charm + + @trace_charm( + tracing_endpoint="my_tracing_endpoint", + ) + class MyCharm(CharmBase): + + ... + + @property + def my_tracing_endpoint(self) -> Optional[str]: + '''Tempo endpoint for charm tracing''' + if self.tracing.is_ready(): + return self.tracing.get_endpoint("otlp_http") # NEW API, use this. + else: + return None +``` + +3) If you were passing a certificate (str) using `server_cert`, you need to change it to +provide an *absolute* path to the certificate file instead. +""" + + +def _remove_stale_otel_sdk_packages(): + """Hack to remove stale opentelemetry sdk packages from the charm's python venv. + + See https://github.com/canonical/grafana-agent-operator/issues/146 and + https://bugs.launchpad.net/juju/+bug/2058335 for more context. This patch can be removed after + this juju issue is resolved and sufficient time has passed to expect most users of this library + have migrated to the patched version of juju. When this patch is removed, un-ignore rule E402 for this file in the pyproject.toml (see setting + [tool.ruff.lint.per-file-ignores] in pyproject.toml). + + This only has an effect if executed on an upgrade-charm event. + """ + # all imports are local to keep this function standalone, side-effect-free, and easy to revert later + import os + + if os.getenv("JUJU_DISPATCH_PATH") != "hooks/upgrade-charm": + return + + import logging + import shutil + from collections import defaultdict + + from importlib_metadata import distributions + + otel_logger = logging.getLogger("charm_tracing_otel_patcher") + otel_logger.debug("Applying _remove_stale_otel_sdk_packages patch on charm upgrade") + # group by name all distributions starting with "opentelemetry_" + otel_distributions = defaultdict(list) + for distribution in distributions(): + name = distribution._normalized_name # type: ignore + if name.startswith("opentelemetry_"): + otel_distributions[name].append(distribution) + + otel_logger.debug(f"Found {len(otel_distributions)} opentelemetry distributions") + + # If we have multiple distributions with the same name, remove any that have 0 associated files + for name, distributions_ in otel_distributions.items(): + if len(distributions_) <= 1: + continue + + otel_logger.debug(f"Package {name} has multiple ({len(distributions_)}) distributions.") + for distribution in distributions_: + if not distribution.files: # Not None or empty list + path = distribution._path # type: ignore + otel_logger.info(f"Removing empty distribution of {name} at {path}.") + shutil.rmtree(path) + + otel_logger.debug("Successfully applied _remove_stale_otel_sdk_packages patch. ") + + +_remove_stale_otel_sdk_packages() + +import functools +import inspect +import logging +import os +from contextlib import contextmanager +from contextvars import Context, ContextVar, copy_context +from pathlib import Path +from typing import ( + Any, + Callable, + Generator, + Optional, + Sequence, + Type, + TypeVar, + Union, + cast, +) + +import opentelemetry +import ops +from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter +from opentelemetry.sdk.resources import Resource +from opentelemetry.sdk.trace import Span, TracerProvider +from opentelemetry.sdk.trace.export import BatchSpanProcessor +from opentelemetry.trace import INVALID_SPAN, Tracer +from opentelemetry.trace import get_current_span as otlp_get_current_span +from opentelemetry.trace import ( + get_tracer, + get_tracer_provider, + set_span_in_context, + set_tracer_provider, +) +from ops.charm import CharmBase +from ops.framework import Framework + +# The unique Charmhub library identifier, never change it +LIBID = "01780f1e588c42c3976d26780fdf9b89" + +# 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 = 2 + +PYDEPS = ["opentelemetry-exporter-otlp-proto-http==1.21.0"] + +logger = logging.getLogger("tracing") +dev_logger = logging.getLogger("tracing-dev") + +# set this to 0 if you are debugging/developing this library source +dev_logger.setLevel(logging.CRITICAL) + +_CharmType = Type[CharmBase] # the type CharmBase and any subclass thereof +_C = TypeVar("_C", bound=_CharmType) +_T = TypeVar("_T", bound=type) +_F = TypeVar("_F", bound=Type[Callable]) +tracer: ContextVar[Tracer] = ContextVar("tracer") +_GetterType = Union[Callable[[_CharmType], Optional[str]], property] + +CHARM_TRACING_ENABLED = "CHARM_TRACING_ENABLED" + + +def is_enabled() -> bool: + """Whether charm tracing is enabled.""" + return os.getenv(CHARM_TRACING_ENABLED, "1") == "1" + + +@contextmanager +def charm_tracing_disabled(): + """Contextmanager to temporarily disable charm tracing. + + For usage in tests. + """ + previous = os.getenv(CHARM_TRACING_ENABLED, "1") + os.environ[CHARM_TRACING_ENABLED] = "0" + yield + os.environ[CHARM_TRACING_ENABLED] = previous + + +def get_current_span() -> Union[Span, None]: + """Return the currently active Span, if there is one, else None. + + If you'd rather keep your logic unconditional, you can use opentelemetry.trace.get_current_span, + which will return an object that behaves like a span but records no data. + """ + span = otlp_get_current_span() + if span is INVALID_SPAN: + return None + return cast(Span, span) + + +def _get_tracer_from_context(ctx: Context) -> Optional[ContextVar]: + tracers = [v for v in ctx if v is not None and v.name == "tracer"] + if tracers: + return tracers[0] + return None + + +def _get_tracer() -> Optional[Tracer]: + """Find tracer in context variable and as a fallback locate it in the full context.""" + try: + return tracer.get() + except LookupError: + # fallback: this course-corrects for a user error where charm_tracing symbols are imported + # from different paths (typically charms.tempo_coordinator_k8s... and lib.charms.tempo_coordinator_k8s...) + try: + ctx: Context = copy_context() + if context_tracer := _get_tracer_from_context(ctx): + logger.warning( + "Tracer not found in `tracer` context var. " + "Verify that you're importing all `charm_tracing` symbols from the same module path. \n" + "For example, DO" + ": `from charms.lib...charm_tracing import foo, bar`. \n" + "DONT: \n" + " \t - `from charms.lib...charm_tracing import foo` \n" + " \t - `from lib...charm_tracing import bar` \n" + "For more info: https://python-notes.curiousefficiency.org/en/latest/python" + "_concepts/import_traps.html#the-double-import-trap" + ) + return context_tracer.get() + else: + return None + except LookupError: + return None + + +@contextmanager +def _span(name: str) -> Generator[Optional[Span], Any, Any]: + """Context to create a span if there is a tracer, otherwise do nothing.""" + if tracer := _get_tracer(): + with tracer.start_as_current_span(name) as span: + yield cast(Span, span) + else: + yield None + + +class TracingError(RuntimeError): + """Base class for errors raised by this module.""" + + +class UntraceableObjectError(TracingError): + """Raised when an object you're attempting to instrument cannot be autoinstrumented.""" + + +def _get_tracing_endpoint( + tracing_endpoint_attr: str, + charm_instance: object, + charm_type: type, +): + _tracing_endpoint = getattr(charm_instance, tracing_endpoint_attr) + if callable(_tracing_endpoint): + tracing_endpoint = _tracing_endpoint() + else: + tracing_endpoint = _tracing_endpoint + + if tracing_endpoint is None: + return + + elif not isinstance(tracing_endpoint, str): + raise TypeError( + f"{charm_type.__name__}.{tracing_endpoint_attr} should resolve to a tempo endpoint (string); " + f"got {tracing_endpoint} instead." + ) + + dev_logger.debug(f"Setting up span exporter to endpoint: {tracing_endpoint}/v1/traces") + return f"{tracing_endpoint}/v1/traces" + + +def _get_server_cert( + server_cert_attr: str, + charm_instance: ops.CharmBase, + charm_type: Type[ops.CharmBase], +): + _server_cert = getattr(charm_instance, server_cert_attr) + if callable(_server_cert): + server_cert = _server_cert() + else: + server_cert = _server_cert + + if server_cert is None: + logger.warning( + f"{charm_type}.{server_cert_attr} is None; sending traces over INSECURE connection." + ) + return + elif not Path(server_cert).is_absolute(): + raise ValueError( + f"{charm_type}.{server_cert_attr} should resolve to a valid tls cert absolute path (string | Path)); " + f"got {server_cert} instead." + ) + return server_cert + + +def _setup_root_span_initializer( + charm_type: _CharmType, + tracing_endpoint_attr: str, + server_cert_attr: Optional[str], + service_name: Optional[str] = None, +): + """Patch the charm's initializer.""" + original_init = charm_type.__init__ + + @functools.wraps(original_init) + def wrap_init(self: CharmBase, framework: Framework, *args, **kwargs): + # we're using 'self' here because this is charm init code, makes sense to read what's below + # from the perspective of the charm. Self.unit.name... + + original_init(self, framework, *args, **kwargs) + # we call this from inside the init context instead of, say, _autoinstrument, because we want it to + # be checked on a per-charm-instantiation basis, not on a per-type-declaration one. + if not is_enabled(): + # this will only happen during unittesting, hopefully, so it's fine to log a + # bit more verbosely + logger.info("Tracing DISABLED: skipping root span initialization") + return + + # already init some attrs that will be reinited later by calling original_init: + # self.framework = framework + # self.handle = Handle(None, self.handle_kind, None) + + original_event_context = framework._event_context + # default service name isn't just app name because it could conflict with the workload service name + _service_name = service_name or f"{self.app.name}-charm" + + unit_name = self.unit.name + # apply hacky patch to remove stale opentelemetry sdk packages on upgrade-charm. + # it could be trouble if someone ever decides to implement their own tracer parallel to + # ours and before the charm has inited. We assume they won't. + resource = Resource.create( + attributes={ + "service.name": _service_name, + "compose_service": _service_name, + "charm_type": type(self).__name__, + # juju topology + "juju_unit": unit_name, + "juju_application": self.app.name, + "juju_model": self.model.name, + "juju_model_uuid": self.model.uuid, + } + ) + provider = TracerProvider(resource=resource) + + # if anything goes wrong with retrieving the endpoint, we let the exception bubble up. + tracing_endpoint = _get_tracing_endpoint(tracing_endpoint_attr, self, charm_type) + + if not tracing_endpoint: + # tracing is off if tracing_endpoint is None + return + + server_cert: Optional[Union[str, Path]] = ( + _get_server_cert(server_cert_attr, self, charm_type) if server_cert_attr else None + ) + + if tracing_endpoint.startswith("https://") and not server_cert: + logger.error( + "Tracing endpoint is https, but no server_cert has been passed." + "Please point @trace_charm to a `server_cert` attr. " + "This might also mean that the tracing provider is related to a " + "certificates provider, but this application is not (yet). " + "In that case, you might just have to wait a bit for the certificates " + "integration to settle. " + ) + return + + exporter = OTLPSpanExporter( + endpoint=tracing_endpoint, + certificate_file=str(Path(server_cert).absolute()) if server_cert else None, + timeout=2, + ) + + processor = BatchSpanProcessor(exporter) + provider.add_span_processor(processor) + set_tracer_provider(provider) + _tracer = get_tracer(_service_name) # type: ignore + _tracer_token = tracer.set(_tracer) + + dispatch_path = os.getenv("JUJU_DISPATCH_PATH", "") # something like hooks/install + event_name = dispatch_path.split("/")[1] if "/" in dispatch_path else dispatch_path + root_span_name = f"{unit_name}: {event_name} event" + span = _tracer.start_span(root_span_name, attributes={"juju.dispatch_path": dispatch_path}) + + # all these shenanigans are to work around the fact that the opentelemetry tracing API is built + # on the assumption that spans will be used as contextmanagers. + # Since we don't (as we need to close the span on framework.commit), + # we need to manually set the root span as current. + ctx = set_span_in_context(span) + + # log a trace id, so we can pick it up from the logs (and jhack) to look it up in tempo. + root_trace_id = hex(span.get_span_context().trace_id)[2:] # strip 0x prefix + logger.debug(f"Starting root trace with id={root_trace_id!r}.") + + span_token = opentelemetry.context.attach(ctx) # type: ignore + + @contextmanager + def wrap_event_context(event_name: str): + dev_logger.info(f"entering event context: {event_name}") + # when the framework enters an event context, we create a span. + with _span("event: " + event_name) as event_context_span: + if event_context_span: + # todo: figure out how to inject event attrs in here + event_context_span.add_event(event_name) + yield original_event_context(event_name) + + framework._event_context = wrap_event_context # type: ignore + + original_close = framework.close + + @functools.wraps(original_close) + def wrap_close(): + dev_logger.info("tearing down tracer and flushing traces") + span.end() + opentelemetry.context.detach(span_token) # type: ignore + tracer.reset(_tracer_token) + tp = cast(TracerProvider, get_tracer_provider()) + tp.force_flush(timeout_millis=1000) # don't block for too long + tp.shutdown() + original_close() + + framework.close = wrap_close + return + + charm_type.__init__ = wrap_init # type: ignore + + +def trace_charm( + tracing_endpoint: str, + server_cert: Optional[str] = None, + service_name: Optional[str] = None, + extra_types: Sequence[type] = (), +) -> Callable[[_T], _T]: + """Autoinstrument the decorated charm with tracing telemetry. + + Use this function to get out-of-the-box traces for all events emitted on this charm and all + method calls on instances of this class. + + Usage: + >>> from charms.tempo_coordinator_k8s.v0.charm_tracing import trace_charm + >>> from charms.tempo_coordinator_k8s.v0.tracing import TracingEndpointRequirer + >>> from ops import CharmBase + >>> + >>> @trace_charm( + >>> tracing_endpoint="tempo_otlp_http_endpoint", + >>> ) + >>> class MyCharm(CharmBase): + >>> + >>> def __init__(self, framework: Framework): + >>> ... + >>> self.tracing = TracingEndpointRequirer(self) + >>> + >>> @property + >>> def tempo_otlp_http_endpoint(self) -> Optional[str]: + >>> if self.tracing.is_ready(): + >>> return self.tracing.otlp_http_endpoint() + >>> else: + >>> return None + >>> + + :param tracing_endpoint: name of a method, property or attribute on the charm type that returns an + optional (fully resolvable) tempo url to which the charm traces will be pushed. + If None, tracing will be effectively disabled. + :param server_cert: name of a method, property or attribute on the charm type that returns an + optional absolute path to a CA certificate file to be used when sending traces to a remote server. + If it returns None, an _insecure_ connection will be used. To avoid errors in transient + situations where the endpoint is already https but there is no certificate on disk yet, it + is recommended to disable tracing (by returning None from the tracing_endpoint) altogether + until the cert has been written to disk. + :param service_name: service name tag to attach to all traces generated by this charm. + Defaults to the juju application name this charm is deployed under. + :param extra_types: pass any number of types that you also wish to autoinstrument. + For example, charm libs, relation endpoint wrappers, workload abstractions, ... + """ + + def _decorator(charm_type: _T) -> _T: + """Autoinstrument the wrapped charmbase type.""" + _autoinstrument( + charm_type, + tracing_endpoint_attr=tracing_endpoint, + server_cert_attr=server_cert, + service_name=service_name, + extra_types=extra_types, + ) + return charm_type + + return _decorator + + +def _autoinstrument( + charm_type: _T, + tracing_endpoint_attr: str, + server_cert_attr: Optional[str] = None, + service_name: Optional[str] = None, + extra_types: Sequence[type] = (), +) -> _T: + """Set up tracing on this charm class. + + Use this function to get out-of-the-box traces for all events emitted on this charm and all + method calls on instances of this class. + + Usage: + + >>> from charms.tempo_coordinator_k8s.v0.charm_tracing import _autoinstrument + >>> from ops.main import main + >>> _autoinstrument( + >>> MyCharm, + >>> tracing_endpoint_attr="tempo_otlp_http_endpoint", + >>> service_name="MyCharm", + >>> extra_types=(Foo, Bar) + >>> ) + >>> main(MyCharm) + + :param charm_type: the CharmBase subclass to autoinstrument. + :param tracing_endpoint_attr: name of a method, property or attribute on the charm type that returns an + optional (fully resolvable) tempo url to which the charm traces will be pushed. + If None, tracing will be effectively disabled. + :param server_cert_attr: name of a method, property or attribute on the charm type that returns an + optional absolute path to a CA certificate file to be used when sending traces to a remote server. + If it returns None, an _insecure_ connection will be used. To avoid errors in transient + situations where the endpoint is already https but there is no certificate on disk yet, it + is recommended to disable tracing (by returning None from the tracing_endpoint) altogether + until the cert has been written to disk. + :param service_name: service name tag to attach to all traces generated by this charm. + Defaults to the juju application name this charm is deployed under. + :param extra_types: pass any number of types that you also wish to autoinstrument. + For example, charm libs, relation endpoint wrappers, workload abstractions, ... + """ + dev_logger.info(f"instrumenting {charm_type}") + _setup_root_span_initializer( + charm_type, + tracing_endpoint_attr, + server_cert_attr=server_cert_attr, + service_name=service_name, + ) + trace_type(charm_type) + for type_ in extra_types: + trace_type(type_) + + return charm_type + + +def trace_type(cls: _T) -> _T: + """Set up tracing on this class. + + Use this decorator to get out-of-the-box traces for all method calls on instances of this class. + It assumes that this class is only instantiated after a charm type decorated with `@trace_charm` + has been instantiated. + """ + dev_logger.info(f"instrumenting {cls}") + for name, method in inspect.getmembers(cls, predicate=inspect.isfunction): + dev_logger.info(f"discovered {method}") + + if method.__name__.startswith("__"): + dev_logger.info(f"skipping {method} (dunder)") + continue + + # the span title in the general case should be: + # method call: MyCharmWrappedMethods.b + # if the method has a name (functools.wrapped or regular method), let + # _trace_callable use its default algorithm to determine what name to give the span. + trace_method_name = None + try: + qualname_c0 = method.__qualname__.split(".")[0] + if not hasattr(cls, method.__name__): + # if the callable doesn't have a __name__ (probably a decorated method), + # it probably has a bad qualname too (such as my_decorator..wrapper) which is not + # great for finding out what the trace is about. So we use the method name instead and + # add a reference to the decorator name. Result: + # method call: @my_decorator(MyCharmWrappedMethods.b) + trace_method_name = f"@{qualname_c0}({cls.__name__}.{name})" + except Exception: # noqa: failsafe + pass + + new_method = trace_method(method, name=trace_method_name) + + if isinstance(inspect.getattr_static(cls, name), staticmethod): + new_method = staticmethod(new_method) + setattr(cls, name, new_method) + + return cls + + +def trace_method(method: _F, name: Optional[str] = None) -> _F: + """Trace this method. + + A span will be opened when this method is called and closed when it returns. + """ + return _trace_callable(method, "method", name=name) + + +def trace_function(function: _F, name: Optional[str] = None) -> _F: + """Trace this function. + + A span will be opened when this function is called and closed when it returns. + """ + return _trace_callable(function, "function", name=name) + + +def _trace_callable(callable: _F, qualifier: str, name: Optional[str] = None) -> _F: + dev_logger.info(f"instrumenting {callable}") + + # sig = inspect.signature(callable) + @functools.wraps(callable) + def wrapped_function(*args, **kwargs): # type: ignore + name_ = name or getattr( + callable, "__qualname__", getattr(callable, "__name__", str(callable)) + ) + with _span(f"{qualifier} call: {name_}"): # type: ignore + return callable(*args, **kwargs) # type: ignore + + # wrapped_function.__signature__ = sig + return wrapped_function # type: ignore + + +def trace(obj: Union[Type, Callable]): + """Trace this object and send the resulting spans to Tempo. + + It will dispatch to ``trace_type`` if the decorated object is a class, otherwise + ``trace_function``. + """ + if isinstance(obj, type): + if issubclass(obj, CharmBase): + raise ValueError( + "cannot use @trace on CharmBase subclasses: use @trace_charm instead " + "(we need some arguments!)" + ) + return trace_type(obj) + else: + try: + return trace_function(obj) + except Exception: + raise UntraceableObjectError( + f"cannot create span from {type(obj)}; instrument {obj} manually." + ) diff --git a/lib/charms/tempo_k8s/v2/tracing.py b/lib/charms/tempo_coordinator_k8s/v0/tracing.py similarity index 94% rename from lib/charms/tempo_k8s/v2/tracing.py rename to lib/charms/tempo_coordinator_k8s/v0/tracing.py index 8b9fb4f3..1f92867f 100644 --- a/lib/charms/tempo_k8s/v2/tracing.py +++ b/lib/charms/tempo_coordinator_k8s/v0/tracing.py @@ -16,7 +16,7 @@ This relation must use the `tracing` interface. The `TracingEndpointRequirer` object may be instantiated as follows - from charms.tempo_k8s.v2.tracing import TracingEndpointRequirer + from charms.tempo_coordinator_k8s.v0.tracing import TracingEndpointRequirer def __init__(self, *args): super().__init__(*args) @@ -58,7 +58,7 @@ def __init__(self, *args): For example a Tempo charm may instantiate the `TracingEndpointProvider` in its constructor as follows - from charms.tempo_k8s.v2.tracing import TracingEndpointProvider + from charms.tempo_coordinator_k8s.v0.tracing import TracingEndpointProvider def __init__(self, *args): super().__init__(*args) @@ -97,17 +97,17 @@ def __init__(self, *args): ) from ops.framework import EventSource, Object from ops.model import ModelError, Relation -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, Field # The unique Charmhub library identifier, never change it -LIBID = "12977e9aa0b34367903d8afeb8c3d85d" +LIBID = "d2f02b1f8d1244b5989fd55bc3a28943" # Increment this major API version when introducing breaking changes -LIBAPI = 2 +LIBAPI = 0 # Increment this PATCH version before using `charmcraft publish-lib` or reset # to 0 if you are raising the major API version -LIBPATCH = 7 +LIBPATCH = 2 PYDEPS = ["pydantic"] @@ -116,14 +116,13 @@ def __init__(self, *args): DEFAULT_RELATION_NAME = "tracing" RELATION_INTERFACE_NAME = "tracing" +# Supported list rationale https://github.com/canonical/tempo-coordinator-k8s-operator/issues/8 ReceiverProtocol = Literal[ "zipkin", - "kafka", - "opencensus", - "tempo_http", - "tempo_grpc", "otlp_grpc", "otlp_http", + "jaeger_grpc", + "jaeger_thrift_http", ] RawReceiver = Tuple[ReceiverProtocol, str] @@ -141,14 +140,12 @@ class TransportProtocolType(str, enum.Enum): grpc = "grpc" -receiver_protocol_to_transport_protocol = { +receiver_protocol_to_transport_protocol: Dict[ReceiverProtocol, TransportProtocolType] = { "zipkin": TransportProtocolType.http, - "kafka": TransportProtocolType.http, - "opencensus": TransportProtocolType.http, - "tempo_http": TransportProtocolType.http, - "tempo_grpc": TransportProtocolType.grpc, "otlp_grpc": TransportProtocolType.grpc, "otlp_http": TransportProtocolType.http, + "jaeger_thrift_http": TransportProtocolType.http, + "jaeger_grpc": TransportProtocolType.grpc, } """A mapping between telemetry protocols and their corresponding transport protocol. """ @@ -341,7 +338,7 @@ class Config: class ProtocolType(BaseModel): """Protocol Type.""" - model_config = ConfigDict( + model_config = ConfigDict( # type: ignore # Allow serializing enum values. use_enum_values=True ) @@ -905,7 +902,16 @@ def _get_endpoint( def get_endpoint( self, protocol: ReceiverProtocol, relation: Optional[Relation] = None ) -> Optional[str]: - """Receiver endpoint for the given protocol.""" + """Receiver endpoint for the given protocol. + + It could happen that this function gets called before the provider publishes the endpoints. + In such a scenario, if a non-leader unit calls this function, a permission denied exception will be raised due to + restricted access. To prevent this, this function needs to be guarded by the `is_ready` check. + + Raises: + ProtocolNotRequestedError: + If the charm unit is the leader unit and attempts to obtain an endpoint for a protocol it did not request. + """ endpoint = self._get_endpoint(relation or self._relation, protocol=protocol) if not endpoint: requested_protocols = set() @@ -928,7 +934,7 @@ def get_endpoint( def charm_tracing_config( endpoint_requirer: TracingEndpointRequirer, cert_path: Optional[Union[Path, str]] ) -> Tuple[Optional[str], Optional[str]]: - """Utility function to determine the charm_tracing config you will likely want. + """Return the charm_tracing config you likely want. If no endpoint is provided: disable charm tracing. @@ -941,8 +947,8 @@ def charm_tracing_config( Usage: If you are using charm_tracing >= v1.9: - >>> from lib.charms.tempo_k8s.v1.charm_tracing import trace_charm - >>> from lib.charms.tempo_k8s.v2.tracing import charm_tracing_config + >>> from lib.charms.tempo_coordinator_k8s.v0.charm_tracing import trace_charm + >>> from lib.charms.tempo_coordinator_k8s.v0.tracing import charm_tracing_config >>> @trace_charm(tracing_endpoint="my_endpoint", cert_path="cert_path") >>> class MyCharm(...): >>> _cert_path = "/path/to/cert/on/charm/container.crt" @@ -952,8 +958,8 @@ def charm_tracing_config( ... self.tracing, self._cert_path) If you are using charm_tracing < v1.9: - >>> from lib.charms.tempo_k8s.v1.charm_tracing import trace_charm - >>> from lib.charms.tempo_k8s.v2.tracing import charm_tracing_config + >>> from lib.charms.tempo_coordinator_k8s.v0.charm_tracing import trace_charm + >>> from lib.charms.tempo_coordinator_k8s.v0.tracing import charm_tracing_config >>> @trace_charm(tracing_endpoint="my_endpoint", cert_path="cert_path") >>> class MyCharm(...): >>> _cert_path = "/path/to/cert/on/charm/container.crt" @@ -979,11 +985,16 @@ def charm_tracing_config( is_https = endpoint.startswith("https://") if is_https: - if cert_path is None: - raise TracingError("Cannot send traces to an https endpoint without a certificate.") - elif not Path(cert_path).exists(): - # if endpoint is https BUT we don't have a server_cert yet: - # disable charm tracing until we do to prevent tls errors + if cert_path is None or not Path(cert_path).exists(): + # disable charm tracing until we obtain a cert to prevent tls errors + logger.error( + "Tracing endpoint is https, but no server_cert has been passed." + "Please point @trace_charm to a `server_cert` attr. " + "This might also mean that the tracing provider is related to a " + "certificates provider, but this application is not (yet). " + "In that case, you might just have to wait a bit for the certificates " + "integration to settle. " + ) return None, None return endpoint, str(cert_path) else: diff --git a/lib/charms/tempo_k8s/v1/charm_tracing.py b/lib/charms/tempo_k8s/v1/charm_tracing.py deleted file mode 100644 index 5932c8d8..00000000 --- a/lib/charms/tempo_k8s/v1/charm_tracing.py +++ /dev/null @@ -1,568 +0,0 @@ -#!/usr/bin/env python3 -# Copyright 2022 Canonical Ltd. -# See LICENSE file for licensing details. - -"""This charm library contains utilities to instrument your Charm with opentelemetry tracing data collection. - -(yes! charm code, not workload code!) - -This means that, if your charm is related to, for example, COS' Tempo charm, you will be able to inspect -in real time from the Grafana dashboard the execution flow of your charm. - -To start using this library, you need to do two things: -1) decorate your charm class with - -`@trace_charm(tracing_endpoint="my_tracing_endpoint")` - -2) add to your charm a "my_tracing_endpoint" (you can name this attribute whatever you like) **property** -that returns an otlp http/https endpoint url. If you are using the `TracingEndpointProvider` as -`self.tracing = TracingEndpointProvider(self)`, the implementation could be: - -``` - @property - def my_tracing_endpoint(self) -> Optional[str]: - '''Tempo endpoint for charm tracing''' - if self.tracing.is_ready(): - return self.tracing.otlp_http_endpoint() - else: - return None -``` - -At this point your charm will be automatically instrumented so that: -- charm execution starts a trace, containing - - every event as a span (including custom events) - - every charm method call (except dunders) as a span - -if you wish to add more fine-grained information to the trace, you can do so by getting a hold of the tracer like so: -``` -import opentelemetry -... - @property - def tracer(self) -> opentelemetry.trace.Tracer: - return opentelemetry.trace.get_tracer(type(self).__name__) -``` - -By default, the tracer is named after the charm type. If you wish to override that, you can pass -a different `service_name` argument to `trace_charm`. - -*Upgrading from `v0`:* - -If you are upgrading from `charm_tracing` v0, you need to take the following steps (assuming you already -have the newest version of the library in your charm): -1) If you need the dependency for your tests, add the following dependency to your charm project -(or, if your project had a dependency on `opentelemetry-exporter-otlp-proto-grpc` only because -of `charm_tracing` v0, you can replace it with): - -`opentelemetry-exporter-otlp-proto-http>=1.21.0`. - -2) Update the charm method referenced to from `@trace` and `@trace_charm`, -to return from `TracingEndpointRequirer.otlp_http_endpoint()` instead of `grpc_http`. For example: - -``` - from charms.tempo_k8s.v0.charm_tracing import trace_charm - - @trace_charm( - tracing_endpoint="my_tracing_endpoint", - ) - class MyCharm(CharmBase): - - ... - - @property - def my_tracing_endpoint(self) -> Optional[str]: - '''Tempo endpoint for charm tracing''' - if self.tracing.is_ready(): - return self.tracing.otlp_grpc_endpoint() - else: - return None -``` - -needs to be replaced with: - -``` - from charms.tempo_k8s.v1.charm_tracing import trace_charm - - @trace_charm( - tracing_endpoint="my_tracing_endpoint", - ) - class MyCharm(CharmBase): - - ... - - @property - def my_tracing_endpoint(self) -> Optional[str]: - '''Tempo endpoint for charm tracing''' - if self.tracing.is_ready(): - return self.tracing.otlp_http_endpoint() - else: - return None -``` - -3) If you were passing a certificate using `server_cert`, you need to change it to provide an *absolute* path to -the certificate file. -""" - -import functools -import inspect -import logging -import os -from contextlib import contextmanager -from contextvars import Context, ContextVar, copy_context -from pathlib import Path -from typing import ( - Any, - Callable, - Generator, - Optional, - Sequence, - Type, - TypeVar, - Union, - cast, -) - -import opentelemetry -from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter -from opentelemetry.sdk.resources import Resource -from opentelemetry.sdk.trace import Span, TracerProvider -from opentelemetry.sdk.trace.export import BatchSpanProcessor -from opentelemetry.trace import INVALID_SPAN, Tracer -from opentelemetry.trace import get_current_span as otlp_get_current_span -from opentelemetry.trace import ( - get_tracer, - get_tracer_provider, - set_span_in_context, - set_tracer_provider, -) -from ops.charm import CharmBase -from ops.framework import Framework - -# The unique Charmhub library identifier, never change it -LIBID = "cb1705dcd1a14ca09b2e60187d1215c7" - -# Increment this major API version when introducing breaking changes -LIBAPI = 1 - -# Increment this PATCH version before using `charmcraft publish-lib` or reset -# to 0 if you are raising the major API version - -LIBPATCH = 9 - -PYDEPS = ["opentelemetry-exporter-otlp-proto-http==1.21.0"] - -logger = logging.getLogger("tracing") - -tracer: ContextVar[Tracer] = ContextVar("tracer") -_GetterType = Union[Callable[[CharmBase], Optional[str]], property] - -CHARM_TRACING_ENABLED = "CHARM_TRACING_ENABLED" - - -def is_enabled() -> bool: - """Whether charm tracing is enabled.""" - return os.getenv(CHARM_TRACING_ENABLED, "1") == "1" - - -@contextmanager -def charm_tracing_disabled(): - """Contextmanager to temporarily disable charm tracing. - - For usage in tests. - """ - previous = os.getenv(CHARM_TRACING_ENABLED, "1") - os.environ[CHARM_TRACING_ENABLED] = "0" - yield - os.environ[CHARM_TRACING_ENABLED] = previous - - -def get_current_span() -> Union[Span, None]: - """Return the currently active Span, if there is one, else None. - - If you'd rather keep your logic unconditional, you can use opentelemetry.trace.get_current_span, - which will return an object that behaves like a span but records no data. - """ - span = otlp_get_current_span() - if span is INVALID_SPAN: - return None - return cast(Span, span) - - -def _get_tracer_from_context(ctx: Context) -> Optional[ContextVar]: - tracers = [v for v in ctx if v is not None and v.name == "tracer"] - if tracers: - return tracers[0] - return None - - -def _get_tracer() -> Optional[Tracer]: - """Find tracer in context variable and as a fallback locate it in the full context.""" - try: - return tracer.get() - except LookupError: - try: - ctx: Context = copy_context() - if context_tracer := _get_tracer_from_context(ctx): - return context_tracer.get() - else: - return None - except LookupError: - return None - - -@contextmanager -def _span(name: str) -> Generator[Optional[Span], Any, Any]: - """Context to create a span if there is a tracer, otherwise do nothing.""" - if tracer := _get_tracer(): - with tracer.start_as_current_span(name) as span: - yield cast(Span, span) - else: - yield None - - -_C = TypeVar("_C", bound=Type[CharmBase]) -_T = TypeVar("_T", bound=type) -_F = TypeVar("_F", bound=Type[Callable]) - - -class TracingError(RuntimeError): - """Base class for errors raised by this module.""" - - -class UntraceableObjectError(TracingError): - """Raised when an object you're attempting to instrument cannot be autoinstrumented.""" - - -def _get_tracing_endpoint(tracing_endpoint_getter, self, charm): - if isinstance(tracing_endpoint_getter, property): - tracing_endpoint = tracing_endpoint_getter.__get__(self) - else: # method or callable - tracing_endpoint = tracing_endpoint_getter(self) - - if tracing_endpoint is None: - logger.debug( - f"{charm}.{tracing_endpoint_getter} returned None; quietly disabling " - f"charm_tracing for the run." - ) - return - elif not isinstance(tracing_endpoint, str): - raise TypeError( - f"{charm}.{tracing_endpoint_getter} should return a tempo endpoint (string); " - f"got {tracing_endpoint} instead." - ) - else: - logger.debug(f"Setting up span exporter to endpoint: {tracing_endpoint}/v1/traces") - return f"{tracing_endpoint}/v1/traces" - - -def _get_server_cert(server_cert_getter, self, charm): - if isinstance(server_cert_getter, property): - server_cert = server_cert_getter.__get__(self) - else: # method or callable - server_cert = server_cert_getter(self) - - if server_cert is None: - logger.warning( - f"{charm}.{server_cert_getter} returned None; sending traces over INSECURE connection." - ) - return - elif not Path(server_cert).is_absolute(): - raise ValueError( - f"{charm}.{server_cert_getter} should return a valid tls cert absolute path (string | Path)); " - f"got {server_cert} instead." - ) - return server_cert - - -def _setup_root_span_initializer( - charm: Type[CharmBase], - tracing_endpoint_getter: _GetterType, - server_cert_getter: Optional[_GetterType], - service_name: Optional[str] = None, -): - """Patch the charm's initializer.""" - 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.info("Tracing DISABLED: skipping root span initialization") - return - - # already init some attrs that will be reinited later by calling original_init: - # self.framework = framework - # self.handle = Handle(None, self.handle_kind, None) - - original_event_context = framework._event_context - # default service name isn't just app name because it could conflict with the workload service name - _service_name = service_name or f"{self.app.name}-charm" - - resource = Resource.create( - attributes={ - "service.name": _service_name, - "compose_service": _service_name, - "charm_type": type(self).__name__, - # juju topology - "juju_unit": self.unit.name, - "juju_application": self.app.name, - "juju_model": self.model.name, - "juju_model_uuid": self.model.uuid, - } - ) - provider = TracerProvider(resource=resource) - try: - tracing_endpoint = _get_tracing_endpoint(tracing_endpoint_getter, self, charm) - except Exception: - # if anything goes wrong with retrieving the endpoint, we go on with tracing disabled. - # better than breaking the charm. - logger.exception( - f"exception retrieving the tracing " - f"endpoint from {charm}.{tracing_endpoint_getter}; " - f"proceeding with charm_tracing DISABLED. " - ) - return - - if not tracing_endpoint: - return - - server_cert: Optional[Union[str, Path]] = ( - _get_server_cert(server_cert_getter, self, charm) if server_cert_getter else None - ) - - exporter = OTLPSpanExporter( - endpoint=tracing_endpoint, - certificate_file=str(Path(server_cert).absolute()) if server_cert else None, - timeout=2, - ) - - processor = BatchSpanProcessor(exporter) - provider.add_span_processor(processor) - set_tracer_provider(provider) - _tracer = get_tracer(_service_name) # type: ignore - _tracer_token = tracer.set(_tracer) - - dispatch_path = os.getenv("JUJU_DISPATCH_PATH", "") - - # all these shenanigans are to work around the fact that the opentelemetry tracing API is built - # on the assumption that spans will be used as contextmanagers. - # Since we don't (as we need to close the span on framework.commit), - # we need to manually set the root span as current. - span = _tracer.start_span("charm exec", attributes={"juju.dispatch_path": dispatch_path}) - ctx = set_span_in_context(span) - - # log a trace id so we can look it up in tempo. - root_trace_id = hex(span.get_span_context().trace_id)[2:] # strip 0x prefix - logger.debug(f"Starting root trace with id={root_trace_id!r}.") - - span_token = opentelemetry.context.attach(ctx) # type: ignore - - @contextmanager - def wrap_event_context(event_name: str): - # when the framework enters an event context, we create a span. - with _span("event: " + event_name) as event_context_span: - if event_context_span: - # todo: figure out how to inject event attrs in here - event_context_span.add_event(event_name) - yield original_event_context(event_name) - - framework._event_context = wrap_event_context # type: ignore - - original_close = framework.close - - @functools.wraps(original_close) - def wrap_close(): - span.end() - opentelemetry.context.detach(span_token) # type: ignore - tracer.reset(_tracer_token) - tp = cast(TracerProvider, get_tracer_provider()) - tp.force_flush(timeout_millis=1000) # don't block for too long - tp.shutdown() - original_close() - - framework.close = wrap_close - return - - charm.__init__ = wrap_init - - -def trace_charm( - tracing_endpoint: str, - server_cert: Optional[str] = None, - service_name: Optional[str] = None, - extra_types: Sequence[type] = (), -): - """Autoinstrument the decorated charm with tracing telemetry. - - Use this function to get out-of-the-box traces for all events emitted on this charm and all - method calls on instances of this class. - - Usage: - >>> from charms.tempo_k8s.v1.charm_tracing import trace_charm - >>> from charms.tempo_k8s.v1.tracing import TracingEndpointProvider - >>> from ops import CharmBase - >>> - >>> @trace_charm( - >>> tracing_endpoint="tempo_otlp_http_endpoint", - >>> ) - >>> class MyCharm(CharmBase): - >>> - >>> def __init__(self, framework: Framework): - >>> ... - >>> self.tracing = TracingEndpointProvider(self) - >>> - >>> @property - >>> def tempo_otlp_http_endpoint(self) -> Optional[str]: - >>> if self.tracing.is_ready(): - >>> return self.tracing.otlp_http_endpoint() - >>> else: - >>> return None - >>> - :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 tracing_endpoint: name of a property on the charm type that returns an - optional (fully resolvable) tempo url. If None, tracing will be effectively disabled. Else, traces will be - pushed to that endpoint. - :param service_name: service name tag to attach to all traces generated by this charm. - Defaults to the juju application name this charm is deployed under. - :param extra_types: pass any number of types that you also wish to autoinstrument. - For example, charm libs, relation endpoint wrappers, workload abstractions, ... - """ - - def _decorator(charm_type: Type[CharmBase]): - """Autoinstrument the wrapped charmbase type.""" - _autoinstrument( - charm_type, - tracing_endpoint_getter=getattr(charm_type, tracing_endpoint), - server_cert_getter=getattr(charm_type, server_cert) if server_cert else None, - service_name=service_name, - extra_types=extra_types, - ) - return charm_type - - return _decorator - - -def _autoinstrument( - charm_type: Type[CharmBase], - tracing_endpoint_getter: _GetterType, - server_cert_getter: Optional[_GetterType] = None, - service_name: Optional[str] = None, - extra_types: Sequence[type] = (), -) -> Type[CharmBase]: - """Set up tracing on this charm class. - - Use this function to get out-of-the-box traces for all events emitted on this charm and all - method calls on instances of this class. - - Usage: - - >>> from charms.tempo_k8s.v1.charm_tracing import _autoinstrument - >>> from ops.main import main - >>> _autoinstrument( - >>> MyCharm, - >>> tracing_endpoint_getter=MyCharm.tempo_otlp_http_endpoint, - >>> service_name="MyCharm", - >>> extra_types=(Foo, Bar) - >>> ) - >>> 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. - This needs to be a valid path to a certificate. - :param tracing_endpoint_getter: method or property on the charm type that returns an - optional tempo url. If None, tracing will be effectively disabled. Else, traces will be - pushed to that endpoint. - :param service_name: service name tag to attach to all traces generated by this charm. - Defaults to the juju application name this charm is deployed under. - :param extra_types: pass any number of types that you also wish to autoinstrument. - For example, charm libs, relation endpoint wrappers, workload abstractions, ... - """ - logger.info(f"instrumenting {charm_type}") - _setup_root_span_initializer( - charm_type, - tracing_endpoint_getter, - server_cert_getter=server_cert_getter, - service_name=service_name, - ) - trace_type(charm_type) - for type_ in extra_types: - trace_type(type_) - - return charm_type - - -def trace_type(cls: _T) -> _T: - """Set up tracing on this class. - - Use this decorator to get out-of-the-box traces for all method calls on instances of this class. - It assumes that this class is only instantiated after a charm type decorated with `@trace_charm` - has been instantiated. - """ - logger.info(f"instrumenting {cls}") - for name, method in inspect.getmembers(cls, predicate=inspect.isfunction): - logger.info(f"discovered {method}") - - if method.__name__.startswith("__"): - logger.info(f"skipping {method} (dunder)") - continue - - new_method = trace_method(method) - if isinstance(inspect.getattr_static(cls, method.__name__), staticmethod): - new_method = staticmethod(new_method) - setattr(cls, name, new_method) - - return cls - - -def trace_method(method: _F) -> _F: - """Trace this method. - - A span will be opened when this method is called and closed when it returns. - """ - return _trace_callable(method, "method") - - -def trace_function(function: _F) -> _F: - """Trace this function. - - A span will be opened when this function is called and closed when it returns. - """ - return _trace_callable(function, "function") - - -def _trace_callable(callable: _F, qualifier: str) -> _F: - logger.info(f"instrumenting {callable}") - - # sig = inspect.signature(callable) - @functools.wraps(callable) - def wrapped_function(*args, **kwargs): # type: ignore - name = getattr(callable, "__qualname__", getattr(callable, "__name__", str(callable))) - with _span(f"{qualifier} call: {name}"): # type: ignore - return callable(*args, **kwargs) # type: ignore - - # wrapped_function.__signature__ = sig - return wrapped_function # type: ignore - - -def trace(obj: Union[Type, Callable]): - """Trace this object and send the resulting spans to Tempo. - - It will dispatch to ``trace_type`` if the decorated object is a class, otherwise - ``trace_function``. - """ - if isinstance(obj, type): - if issubclass(obj, CharmBase): - raise ValueError( - "cannot use @trace on CharmBase subclasses: use @trace_charm instead " - "(we need some arguments!)" - ) - return trace_type(obj) - else: - try: - return trace_function(obj) - except Exception: - raise UntraceableObjectError( - f"cannot create span from {type(obj)}; instrument {obj} manually." - ) diff --git a/requirements.txt b/requirements.txt index c5b34576..fc139c8b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,8 @@ jinja2 < 3 lightkube >= 0.11 markupsafe == 2.0.1 -ops +# pinned to 2.16 as 2.17 breaks our unittests +ops == 2.16 pyyaml urllib3 jsonschema diff --git a/src/charm.py b/src/charm.py index 2419b9a8..bee5a36f 100755 --- a/src/charm.py +++ b/src/charm.py @@ -71,8 +71,8 @@ RelationJoinedEvent, UpgradeCharmEvent, ) -from charms.tempo_k8s.v1.charm_tracing import trace_charm -from charms.tempo_k8s.v2.tracing import TracingEndpointRequirer +from charms.tempo_coordinator_k8s.v0.charm_tracing import trace_charm +from charms.tempo_coordinator_k8s.v0.tracing import TracingEndpointRequirer from ops.framework import StoredState from ops.main import main from ops.model import ActiveStatus, BlockedStatus, MaintenanceStatus, OpenedPort