From 3a0706a27827eef2f2ac71cb59b3f30165abfd98 Mon Sep 17 00:00:00 2001 From: PietroPasotti Date: Wed, 10 Apr 2024 12:25:17 +0200 Subject: [PATCH] Tracing v2 (#318) * pulled tracing lib * ci green * get_endpoint guard if tracing ready * explicit return None --- lib/charms/tempo_k8s/v1/charm_tracing.py | 27 +- lib/charms/tempo_k8s/{v1 => v2}/tracing.py | 553 +++++++++++++++------ src/charm.py | 10 +- 3 files changed, 420 insertions(+), 170 deletions(-) rename lib/charms/tempo_k8s/{v1 => v2}/tracing.py (51%) diff --git a/lib/charms/tempo_k8s/v1/charm_tracing.py b/lib/charms/tempo_k8s/v1/charm_tracing.py index c146e6d3..39ebcd46 100644 --- a/lib/charms/tempo_k8s/v1/charm_tracing.py +++ b/lib/charms/tempo_k8s/v1/charm_tracing.py @@ -146,9 +146,9 @@ def my_tracing_endpoint(self) -> Optional[str]: # Increment this PATCH version before using `charmcraft publish-lib` or reset # to 0 if you are raising the major API version -LIBPATCH = 2 +LIBPATCH = 5 -PYDEPS = ["opentelemetry-exporter-otlp-proto-http>=1.21.0"] +PYDEPS = ["opentelemetry-exporter-otlp-proto-http==1.21.0"] logger = logging.getLogger("tracing") @@ -205,7 +205,7 @@ def _get_tracer() -> Optional[Tracer]: return context_tracer.get() else: return None - except LookupError as err: + except LookupError: return None @@ -240,8 +240,8 @@ def _get_tracing_endpoint(tracing_endpoint_getter, self, charm): if tracing_endpoint is None: logger.debug( - "Charm tracing is disabled. Tracing endpoint is not defined - " - "tracing is not available or relation is not set." + f"{charm}.{tracing_endpoint_getter} returned None; quietly disabling " + f"charm_tracing for the run." ) return elif not isinstance(tracing_endpoint, str): @@ -310,7 +310,18 @@ def wrap_init(self: CharmBase, framework: Framework, *args, **kwargs): } ) provider = TracerProvider(resource=resource) - tracing_endpoint = _get_tracing_endpoint(tracing_endpoint_getter, self, charm) + 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 @@ -528,6 +539,10 @@ def wrapped_function(*args, **kwargs): # type: ignore name = getattr(callable, "__qualname__", getattr(callable, "__name__", str(callable))) with _span(f"{'(static) ' if static else ''}{qualifier} call: {name}"): # type: ignore if static: + # fixme: do we or don't we need [1:]? + # The _trace_callable decorator doesn't always play nice with @staticmethods. + # Sometimes it will receive 'self', sometimes it won't. + # return callable(*args, **kwargs) # type: ignore return callable(*args[1:], **kwargs) # type: ignore return callable(*args, **kwargs) # type: ignore diff --git a/lib/charms/tempo_k8s/v1/tracing.py b/lib/charms/tempo_k8s/v2/tracing.py similarity index 51% rename from lib/charms/tempo_k8s/v1/tracing.py rename to lib/charms/tempo_k8s/v2/tracing.py index 3ffcc044..d466531e 100644 --- a/lib/charms/tempo_k8s/v1/tracing.py +++ b/lib/charms/tempo_k8s/v2/tracing.py @@ -12,27 +12,37 @@ object from this charm library. For the simplest use cases, using the `TracingEndpointRequirer` object only requires instantiating it, typically in the constructor of your charm. The `TracingEndpointRequirer` constructor requires the name of the relation over which a tracing endpoint - is exposed by the Tempo charm. This relation must use the -`tracing` interface. + is exposed by the Tempo charm, and a list of protocols it intends to send traces with. + This relation must use the `tracing` interface. The `TracingEndpointRequirer` object may be instantiated as follows - from charms.tempo_k8s.v1.tracing import TracingEndpointRequirer + from charms.tempo_k8s.v2.tracing import TracingEndpointRequirer def __init__(self, *args): super().__init__(*args) # ... - self.tracing = TracingEndpointRequirer(self) + self.tracing = TracingEndpointRequirer(self, + protocols=['otlp_grpc', 'otlp_http', 'jaeger_http_thrift'] + ) # ... Note that the first argument (`self`) to `TracingEndpointRequirer` is always a reference to the parent charm. -Units of provider charms obtain the tempo endpoint to which they will push their traces by using one -of these `TracingEndpointRequirer` attributes, depending on which protocol they support: -- otlp_grpc_endpoint -- otlp_http_endpoint -- zipkin_endpoint -- tempo_endpoint +Alternatively to providing the list of requested protocols at init time, the charm can do it at +any point in time by calling the +`TracingEndpointRequirer.request_protocols(*protocol:str, relation:Optional[Relation])` method. +Using this method also allows you to use per-relation protocols. + +Units of provider charms obtain the tempo endpoint to which they will push their traces by calling +`TracingEndpointRequirer.get_endpoint(protocol: str)`, where `protocol` is, for example: +- `otlp_grpc` +- `otlp_http` +- `zipkin` +- `tempo` + +If the `protocol` is not in the list of protocols that the charm requested at endpoint set-up time, +the library will raise an error. ## Requirer Library Usage @@ -48,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.v1.tracing import TracingEndpointProvider + from charms.tempo_k8s.v2.tracing import TracingEndpointProvider def __init__(self, *args): super().__init__(*args) @@ -69,6 +79,7 @@ def __init__(self, *args): Literal, MutableMapping, Optional, + Sequence, Tuple, cast, ) @@ -83,37 +94,57 @@ def __init__(self, *args): ) from ops.framework import EventSource, Object from ops.model import ModelError, Relation -from pydantic import BaseModel, ConfigDict +from pydantic import BaseModel # The unique Charmhub library identifier, never change it LIBID = "12977e9aa0b34367903d8afeb8c3d85d" # Increment this major API version when introducing breaking changes -LIBAPI = 1 +LIBAPI = 2 # Increment this PATCH version before using `charmcraft publish-lib` or reset # to 0 if you are raising the major API version -LIBPATCH = 3 +LIBPATCH = 2 -PYDEPS = ["pydantic>=2"] +PYDEPS = ["pydantic"] logger = logging.getLogger(__name__) DEFAULT_RELATION_NAME = "tracing" RELATION_INTERFACE_NAME = "tracing" -IngesterProtocol = Literal[ - "otlp_grpc", "otlp_http", "zipkin", "tempo", "jaeger_http_thrift", "jaeger_grpc" +ReceiverProtocol = Literal[ + "zipkin", + "kafka", + "opencensus", + "tempo", # legacy, renamed to tempo_http + "tempo_http", + "tempo_grpc", + "otlp_grpc", + "otlp_http", + "jaeger_grpc", + "jaeger_thrift_compact", + "jaeger_thrift_http", + "jaeger_http_thrift", # legacy, renamed to jaeger_thrift_http + "jaeger_thrift_binary", ] -RawIngester = Tuple[IngesterProtocol, int] +RawReceiver = Tuple[ReceiverProtocol, int] BUILTIN_JUJU_KEYS = {"ingress-address", "private-address", "egress-subnets"} -class TracingError(RuntimeError): +class TracingError(Exception): """Base class for custom errors raised by this library.""" +class NotReadyError(TracingError): + """Raised by the provider wrapper if a requirer hasn't published the required data (yet).""" + + +class ProtocolNotRequestedError(TracingError): + """Raised if the user attempts to obtain an endpoint for a protocol it did not request.""" + + class DataValidationError(TracingError): """Raised when data validation fails on IPU relation data.""" @@ -122,71 +153,148 @@ class AmbiguousRelationUsageError(TracingError): """Raised when one wrongly assumes that there can only be one relation on an endpoint.""" -class DatabagModel(BaseModel): - """Base databag model.""" +if int(pydantic.version.VERSION.split(".")[0]) < 2: - model_config = ConfigDict( - # Allow instantiating this class by field name (instead of forcing alias). - populate_by_name=True, - # Custom config key: whether to nest the whole datastructure (as json) - # under a field or spread it out at the toplevel. - _NEST_UNDER=None, - ) # type: ignore - """Pydantic config.""" + class DatabagModel(BaseModel): # type: ignore + """Base databag model.""" - @classmethod - def load(cls, databag: MutableMapping): - """Load this model from a Juju databag.""" - nest_under = cls.model_config.get("_NEST_UNDER") - if nest_under: - return cls.parse_obj(json.loads(databag[nest_under])) + class Config: + """Pydantic config.""" - try: - data = {k: json.loads(v) for k, v in databag.items() if k not in BUILTIN_JUJU_KEYS} - except json.JSONDecodeError as e: - msg = f"invalid databag contents: expecting json. {databag}" - logger.error(msg) - raise DataValidationError(msg) from e + # ignore any extra fields in the databag + extra = "ignore" + """Ignore any extra fields in the databag.""" + allow_population_by_field_name = True + """Allow instantiating this class by field name (instead of forcing alias).""" - try: - return cls.parse_raw(json.dumps(data)) # type: ignore - except pydantic.ValidationError as e: - if not data: - # databag is empty; this is usually expected - raise DataValidationError("empty databag") + _NEST_UNDER = None - msg = f"failed to validate databag contents: {data!r} as {cls}" - logger.debug(msg, exc_info=True) - raise DataValidationError(msg) from e + @classmethod + def load(cls, databag: MutableMapping): + """Load this model from a Juju databag.""" + if cls._NEST_UNDER: + return cls.parse_obj(json.loads(databag[cls._NEST_UNDER])) - def dump(self, databag: Optional[MutableMapping] = None, clear: bool = True): - """Write the contents of this model to Juju databag. + try: + data = { + k: json.loads(v) + for k, v in databag.items() + # Don't attempt to parse model-external values + if k in {f.alias for f in cls.__fields__.values()} + } + except json.JSONDecodeError as e: + msg = f"invalid databag contents: expecting json. {databag}" + logger.error(msg) + raise DataValidationError(msg) from e - :param databag: the databag to write the data to. - :param clear: ensure the databag is cleared before writing it. - """ - if clear and databag: - databag.clear() + try: + return cls.parse_raw(json.dumps(data)) # type: ignore + except pydantic.ValidationError as e: + msg = f"failed to validate databag: {databag}" + logger.debug(msg, exc_info=True) + raise DataValidationError(msg) from e + + def dump(self, databag: Optional[MutableMapping] = None, clear: bool = True): + """Write the contents of this model to Juju databag. + + :param databag: the databag to write the data to. + :param clear: ensure the databag is cleared before writing it. + """ + if clear and databag: + databag.clear() + + if databag is None: + databag = {} + + if self._NEST_UNDER: + databag[self._NEST_UNDER] = self.json(by_alias=True) + return databag + + dct = self.dict() + for key, field in self.__fields__.items(): # type: ignore + value = dct[key] + databag[field.alias or key] = json.dumps(value) + + return databag + +else: + from pydantic import ConfigDict + + class DatabagModel(BaseModel): + """Base databag model.""" + + model_config = ConfigDict( + # ignore any extra fields in the databag + extra="ignore", + # Allow instantiating this class by field name (instead of forcing alias). + populate_by_name=True, + # Custom config key: whether to nest the whole datastructure (as json) + # under a field or spread it out at the toplevel. + _NEST_UNDER=None, # type: ignore + ) + """Pydantic config.""" - if databag is None: - databag = {} - nest_under = self.model_config.get("_NEST_UNDER") - if nest_under: - databag[nest_under] = self.json() + @classmethod + def load(cls, databag: MutableMapping): + """Load this model from a Juju databag.""" + nest_under = cls.model_config.get("_NEST_UNDER") # type: ignore + if nest_under: + return cls.model_validate(json.loads(databag[nest_under])) # type: ignore - dct = self.model_dump() - for key, field in self.model_fields.items(): # type: ignore - value = dct[key] - databag[field.alias or key] = json.dumps(value) + try: + data = { + k: json.loads(v) + for k, v in databag.items() + # Don't attempt to parse model-external values + if k in {(f.alias or n) for n, f in cls.__fields__.items()} + } + except json.JSONDecodeError as e: + msg = f"invalid databag contents: expecting json. {databag}" + logger.error(msg) + raise DataValidationError(msg) from e - return databag + try: + return cls.model_validate_json(json.dumps(data)) # type: ignore + except pydantic.ValidationError as e: + msg = f"failed to validate databag: {databag}" + logger.debug(msg, exc_info=True) + raise DataValidationError(msg) from e + + def dump(self, databag: Optional[MutableMapping] = None, clear: bool = True): + """Write the contents of this model to Juju databag. + + :param databag: the databag to write the data to. + :param clear: ensure the databag is cleared before writing it. + """ + if clear and databag: + databag.clear() + + if databag is None: + databag = {} + nest_under = self.model_config.get("_NEST_UNDER") + if nest_under: + databag[nest_under] = self.model_dump_json( # type: ignore + by_alias=True, + # skip keys whose values are default + exclude_defaults=True, + ) + return databag + + dct = self.model_dump() # type: ignore + for key, field in self.model_fields.items(): # type: ignore + value = dct[key] + if value == field.default: + continue + databag[field.alias or key] = json.dumps(value) + + return databag # todo use models from charm-relation-interfaces -class Ingester(BaseModel): # noqa: D101 - """Ingester data structure.""" +class Receiver(BaseModel): # noqa: D101 + """Receiver data structure.""" - protocol: IngesterProtocol + protocol: ReceiverProtocol port: int @@ -194,7 +302,17 @@ class TracingProviderAppData(DatabagModel): # noqa: D101 """Application databag model for the tracing provider.""" host: str - ingesters: List[Ingester] + """Server hostname.""" + + receivers: List[Receiver] + """Enabled receivers and ports at which they are listening.""" + + +class TracingRequirerAppData(DatabagModel): # noqa: D101 + """Application databag model for the tracing requirer.""" + + receivers: List[ReceiverProtocol] + """Requested receivers.""" class _AutoSnapshotEvent(RelationEvent): @@ -345,20 +463,42 @@ def _validate_relation_by_interface_and_direction( raise TypeError("Unexpected RelationDirection: {}".format(expected_relation_role)) +class RequestEvent(RelationEvent): + """Event emitted when a remote requests a tracing endpoint.""" + + @property + def requested_receivers(self) -> List[ReceiverProtocol]: + """List of receiver protocols that have been requested.""" + relation = self.relation + app = relation.app + if not app: + raise NotReadyError("relation.app is None") + + return TracingRequirerAppData.load(relation.data[app]).receivers + + +class TracingEndpointProviderEvents(CharmEvents): + """TracingEndpointProvider events.""" + + request = EventSource(RequestEvent) + + class TracingEndpointProvider(Object): - """Class representing a trace ingester service.""" + """Class representing a trace receiver service.""" + + on = TracingEndpointProviderEvents() # type: ignore def __init__( self, charm: CharmBase, host: str, - ingesters: List[RawIngester], relation_name: str = DEFAULT_RELATION_NAME, ): """Initialize. Args: charm: a `CharmBase` instance that manages this instance of the Tempo service. + host: address of the node hosting the tempo server. relation_name: an optional string name of the relation between `charm` and the Tempo charmed service. The default is "tracing". @@ -376,50 +516,101 @@ def __init__( charm, relation_name, RELATION_INTERFACE_NAME, RelationRole.provides ) - super().__init__(charm, relation_name) + super().__init__(charm, relation_name + "tracing-provider-v2") self._charm = charm self._host = host - self._ingesters = ingesters self._relation_name = relation_name - events = self._charm.on[relation_name] - self.framework.observe(events.relation_created, self._on_relation_event) - self.framework.observe(events.relation_joined, self._on_relation_event) + self.framework.observe( + self._charm.on[relation_name].relation_joined, self._on_relation_event + ) + self.framework.observe( + self._charm.on[relation_name].relation_created, self._on_relation_event + ) + self.framework.observe( + self._charm.on[relation_name].relation_changed, self._on_relation_event + ) + + def _on_relation_event(self, e: RelationEvent): + """Handle relation created/joined/changed events.""" + if self.is_v2(e.relation): + self.on.request.emit(e.relation) - def _on_relation_event(self, _): - # Generic relation event handler. + def is_v2(self, relation: Relation): + """Attempt to determine if this relation is a tracing v2 relation. + Assumes that the V2 requirer will, as soon as possible (relation-created), + publish the list of requested ingestion receivers (can be empty too). + """ try: - if self._charm.unit.is_leader(): - for relation in self._charm.model.relations[self._relation_name]: - TracingProviderAppData( - host=self._host, - ingesters=[ - Ingester(port=port, protocol=protocol) - for protocol, port in self._ingesters - ], - ).dump(relation.data[self._charm.app]) + self._get_requested_protocols(relation) + except NotReadyError: + return False + return True - except ModelError as e: - # args are bytes - msg = e.args[0] - if isinstance(msg, bytes): - if msg.startswith( - b"ERROR cannot read relation application settings: permission denied" - ): - logger.error( - f"encountered error {e} while attempting to update_relation_data." - f"The relation must be gone." - ) - return - raise + @staticmethod + def _get_requested_protocols(relation: Relation): + app = relation.app + if not app: + raise NotReadyError("relation.app is None") + + try: + databag = TracingRequirerAppData.load(relation.data[app]) + except (json.JSONDecodeError, pydantic.ValidationError, DataValidationError): + logger.info(f"relation {relation} is not ready to talk tracing v2") + raise NotReadyError() + return databag.receivers + + def requested_protocols(self): + """All receiver protocols that have been requested by our related apps.""" + requested_protocols = set() + for relation in self.relations: + try: + protocols = self._get_requested_protocols(relation) + except NotReadyError: + continue + requested_protocols.update(protocols) + return requested_protocols + + @property + def relations(self) -> List[Relation]: + """All v2 relations active on this endpoint.""" + return [r for r in self._charm.model.relations[self._relation_name] if self.is_v2(r)] + + def publish_receivers(self, receivers: Sequence[RawReceiver]): + """Let all requirers know that these receivers are active and listening.""" + if not self._charm.unit.is_leader(): + raise RuntimeError("only leader can do this") + + for relation in self.relations: + try: + TracingProviderAppData( + host=self._host, + receivers=[ + Receiver(port=port, protocol=protocol) for protocol, port in receivers + ], + ).dump(relation.data[self._charm.app]) + + except ModelError as e: + # args are bytes + msg = e.args[0] + if isinstance(msg, bytes): + if msg.startswith( + b"ERROR cannot read relation application settings: permission denied" + ): + logger.error( + f"encountered error {e} while attempting to update_relation_data." + f"The relation must be gone." + ) + continue + raise class EndpointRemovedEvent(RelationBrokenEvent): - """Event representing a change in one of the ingester endpoints.""" + """Event representing a change in one of the receiver endpoints.""" class EndpointChangedEvent(_AutoSnapshotEvent): - """Event representing a change in one of the ingester endpoints.""" + """Event representing a change in one of the receiver endpoints.""" __args__ = ("host", "_ingesters") @@ -428,12 +619,12 @@ class EndpointChangedEvent(_AutoSnapshotEvent): _ingesters = [] # type: List[dict] @property - def ingesters(self) -> List[Ingester]: - """Cast ingesters back from dict.""" - return [Ingester(**i) for i in self._ingesters] + def receivers(self) -> List[Receiver]: + """Cast receivers back from dict.""" + return [Receiver(**i) for i in self._ingesters] -class TracingEndpointEvents(CharmEvents): +class TracingEndpointRequirerEvents(CharmEvents): """TracingEndpointRequirer events.""" endpoint_changed = EventSource(EndpointChangedEvent) @@ -443,12 +634,13 @@ class TracingEndpointEvents(CharmEvents): class TracingEndpointRequirer(Object): """A tracing endpoint for Tempo.""" - on = TracingEndpointEvents() # type: ignore + on = TracingEndpointRequirerEvents() # type: ignore def __init__( self, charm: CharmBase, relation_name: str = DEFAULT_RELATION_NAME, + protocols: Optional[List[ReceiverProtocol]] = None, ): """Construct a tracing requirer for a Tempo charm. @@ -464,6 +656,9 @@ def __init__( and the Tempo charmed service. The default is "tracing". It is strongly advised not to change the default, so that people deploying your charm will have a consistent experience with all other charms that provide tracing endpoints. + protocols: optional list of protocols that the charm intends to send traces with. + The provider will enable receivers for these and only these protocols, + so be sure to enable all protocols the charm or its workload are going to need. Raises: RelationNotFoundError: If there is no relation in the charm's metadata.yaml @@ -490,6 +685,43 @@ def __init__( self.framework.observe(events.relation_changed, self._on_tracing_relation_changed) self.framework.observe(events.relation_broken, self._on_tracing_relation_broken) + if protocols: + self.request_protocols(protocols) + + def request_protocols( + self, protocols: Sequence[ReceiverProtocol], relation: Optional[Relation] = None + ): + """Publish the list of protocols which the provider should activate.""" + # todo: should we check if _is_single_endpoint and len(self.relations) > 1 and raise, here? + relations = [relation] if relation else self.relations + + if not protocols: + # empty sequence + raise ValueError( + "You need to pass a nonempty sequence of protocols to `request_protocols`." + ) + + try: + if self._charm.unit.is_leader(): + for relation in relations: + TracingRequirerAppData( + receivers=list(protocols), + ).dump(relation.data[self._charm.app]) + + except ModelError as e: + # args are bytes + msg = e.args[0] + if isinstance(msg, bytes): + if msg.startswith( + b"ERROR cannot read relation application settings: permission denied" + ): + logger.error( + f"encountered error {e} while attempting to request_protocols." + f"The relation must be gone." + ) + return + raise + @property def relations(self) -> List[Relation]: """The tracing relations associated with this endpoint.""" @@ -522,7 +754,15 @@ def is_ready(self, relation: Optional[Relation] = None): logger.error(f"{relation} event received but there is no relation.app") return False try: - TracingProviderAppData.load(relation.data[relation.app]) + databag = dict(relation.data[relation.app]) + # "ingesters" Might be populated if the provider sees a v1 relation before a v2 requirer has had time to + # publish the 'receivers' list. This will make Tempo incorrectly assume that this is a v1 + # relation, and act accordingly. Later, when the requirer publishes the requested receivers, + # tempo will be able to course-correct. + if "ingesters" in databag: + del databag["ingesters"] + TracingProviderAppData.load(databag) + except (json.JSONDecodeError, pydantic.ValidationError, DataValidationError): logger.info(f"failed validating relation data for {relation}") return False @@ -536,7 +776,7 @@ def _on_tracing_relation_changed(self, event): return data = TracingProviderAppData.load(relation.data[relation.app]) - self.on.endpoint_changed.emit(relation, data.host, [i.dict() for i in data.ingesters]) # type: ignore + self.on.endpoint_changed.emit(relation, data.host, [i.dict() for i in data.receivers]) # type: ignore def _on_tracing_relation_broken(self, event: RelationBrokenEvent): """Notify the providers that the endpoint is broken.""" @@ -551,65 +791,60 @@ def get_all_endpoints( return return TracingProviderAppData.load(relation.data[relation.app]) # type: ignore - def _get_ingester( - self, relation: Optional[Relation], protocol: IngesterProtocol, ssl: bool = False + def _get_endpoint( + self, relation: Optional[Relation], protocol: ReceiverProtocol, ssl: bool = False ): ep = self.get_all_endpoints(relation) if not ep: return None try: - ingester: Ingester = next(filter(lambda i: i.protocol == protocol, ep.ingesters)) - if ingester.protocol in ["otlp_grpc", "jaeger_grpc"]: + receiver: Receiver = next(filter(lambda i: i.protocol == protocol, ep.receivers)) + if receiver.protocol in ["otlp_grpc", "jaeger_grpc"]: if ssl: logger.warning("unused ssl argument - was the right protocol called?") - return f"{ep.host}:{ingester.port}" + return f"{ep.host}:{receiver.port}" if ssl: - return f"https://{ep.host}:{ingester.port}" - return f"http://{ep.host}:{ingester.port}" + return f"https://{ep.host}:{receiver.port}" + return f"http://{ep.host}:{receiver.port}" except StopIteration: - logger.error(f"no ingester found with protocol={protocol!r}") + logger.error(f"no receiver found with protocol={protocol!r}") return None - def otlp_grpc_endpoint(self, relation: Optional[Relation] = None) -> Optional[str]: - """Ingester endpoint for the ``otlp_grpc`` protocol.""" - return self._get_ingester(relation or self._relation, protocol="otlp_grpc") - - def otlp_http_endpoint( - self, relation: Optional[Relation] = None, ssl: bool = False - ) -> Optional[str]: - """Ingester endpoint for the ``otlp_http`` protocol. - - The provided endpoint does not contain the endpoint suffix. If the instrumenting library needs the full path, - your endpoint code needs to add the ``/v1/traces`` suffix. - """ - return self._get_ingester(relation or self._relation, protocol="otlp_http", ssl=ssl) - - def zipkin_endpoint( - self, relation: Optional[Relation] = None, ssl: bool = False - ) -> Optional[str]: - """Ingester endpoint for the ``zipkin`` protocol. - - The provided endpoint does not contain the endpoint suffix. If the instrumenting library needs the full path, - your endpoint code needs to add the ``/api/v2/spans`` suffix. - """ - return self._get_ingester(relation or self._relation, protocol="zipkin", ssl=ssl) - - def tempo_endpoint( - self, relation: Optional[Relation] = None, ssl: bool = False + def get_endpoint( + self, protocol: ReceiverProtocol, relation: Optional[Relation] = None ) -> Optional[str]: - """Ingester endpoint for the ``tempo`` protocol.""" - return self._get_ingester(relation or self._relation, protocol="tempo", ssl=ssl) + """Receiver endpoint for the given protocol.""" + endpoint = self._get_endpoint(relation or self._relation, protocol=protocol) + if not endpoint: + requested_protocols = set() + relations = [relation] if relation else self.relations + for relation in relations: + try: + databag = TracingRequirerAppData.load(relation.data[self._charm.app]) + except DataValidationError: + continue + + requested_protocols.update(databag.receivers) + + if protocol not in requested_protocols: + raise ProtocolNotRequestedError(protocol, relation) - def jaeger_http_thrift_endpoint( - self, relation: Optional[Relation] = None, ssl: bool = False - ) -> Optional[str]: - """Ingester endpoint for the ``jaeger_http_thrift`` protocol. - - The provided endpoint does not contain the endpoint suffix. If the instrumenting library needs the full path, - your endpoint code needs to add the ``/api/traces`` suffix. - """ - return self._get_ingester(relation or self._relation, "jaeger_http_thrift", ssl=ssl) + return None + return endpoint + + # for backwards compatibility with earlier revisions: + def otlp_grpc_endpoint(self): + """Use TracingEndpointRequirer.get_endpoint('otlp_grpc') instead.""" + logger.warning( + "`TracingEndpointRequirer.otlp_grpc_endpoint` is deprecated. " + "Use `TracingEndpointRequirer.get_endpoint('otlp_grpc') instead.`" + ) + return self.get_endpoint("otlp_grpc") - def jaeger_grpc_endpoint(self, relation: Optional[Relation] = None) -> Optional[str]: - """Ingester endpoint for the ``jaeger_grpc`` protocol.""" - return self._get_ingester(relation or self._relation, "jaeger_grpc") + def otlp_http_endpoint(self): + """Use TracingEndpointRequirer.get_endpoint('otlp_http') instead.""" + logger.warning( + "`TracingEndpointRequirer.otlp_http_endpoint` is deprecated. " + "Use `TracingEndpointRequirer.get_endpoint('otlp_http') instead.`" + ) + return self.get_endpoint("otlp_http") diff --git a/src/charm.py b/src/charm.py index 1ac52d78..b127ccce 100755 --- a/src/charm.py +++ b/src/charm.py @@ -72,7 +72,7 @@ UpgradeCharmEvent, ) from charms.tempo_k8s.v1.charm_tracing import trace_charm -from charms.tempo_k8s.v1.tracing import TracingEndpointRequirer +from charms.tempo_k8s.v2.tracing import TracingEndpointRequirer from ops.framework import StoredState from ops.main import main from ops.model import ActiveStatus, BlockedStatus, MaintenanceStatus, OpenedPort @@ -193,9 +193,7 @@ def __init__(self, *args): self.cert_handler.on.cert_changed, # pyright: ignore ], ) - - # todo when we bump to v2, add otlp_http for charm_tracing and otlp_grpc for the workload. - self.tracing = TracingEndpointRequirer(self) + self.tracing = TracingEndpointRequirer(self, protocols=["otlp_http", "otlp_grpc"]) # -- standard events self.framework.observe(self.on.install, self._on_install) @@ -1516,7 +1514,9 @@ def _on_oauth_info_changed(self, event: OAuthInfoChangedEvent) -> None: @property def tracing_endpoint(self) -> Optional[str]: """Tempo endpoint for charm tracing.""" - return self.tracing.otlp_http_endpoint() + if self.tracing.is_ready(): + return self.tracing.get_endpoint("otlp_http") + return None @property def server_cert_path(self) -> Optional[str]: