-
Notifications
You must be signed in to change notification settings - Fork 124
chore: rewrite vendored tracing charm lib in pure Python #2020
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -16,6 +16,7 @@ | |
|
||
from __future__ import annotations | ||
|
||
import json | ||
import logging | ||
|
||
import opentelemetry.trace | ||
|
@@ -218,3 +219,199 @@ def _get_ca(self) -> str | None: | |
return None | ||
|
||
return '\n'.join(sorted(ca_list)) | ||
|
||
|
||
class Tracing2(ops.Object): | ||
"""Initialise the tracing service. | ||
|
||
Usage: | ||
- Include ``ops[tracing]`` in your dependencies. | ||
- Declare the relations that the charm supports. | ||
- Initialise ``Tracing`` with the names of these relations. | ||
|
||
Example:: | ||
|
||
# charmcraft.yaml | ||
requires: | ||
charm-tracing: | ||
interface: tracing | ||
limit: 1 | ||
optional: true | ||
receive-ca-cert: | ||
interface: certificate_transfer | ||
limit: 1 | ||
optional: true | ||
|
||
# src/charm.py | ||
import ops.tracing | ||
|
||
class SomeCharm(ops.CharmBase): | ||
def __init__(self, framework: ops.Framework): | ||
... | ||
self.tracing = ops.tracing.Tracing( | ||
self, | ||
tracing_relation_name="charm-tracing", | ||
ca_relation_name="receive-ca-cert", | ||
) | ||
|
||
Args: | ||
charm: your charm instance | ||
tracing_relation_name: the name of the relation that provides the | ||
destination to send trace data to. | ||
ca_relation_name: the name of the relation that provides the CA | ||
list to validate the tracing destination against. | ||
ca_data: a fixed CA list (PEM bundle, a multi-line string). | ||
|
||
If the destination is resolved to an HTTPS URL, a CA list is required | ||
to establish a secure connection. | ||
|
||
The CA list can be provided over a relation via the ``ca_relation_name`` | ||
argument, as a fixed string via the ``ca_data`` argument, or the system CA | ||
list will be used if the earlier two are both ``None``. | ||
""" | ||
|
||
def __init__( | ||
self, | ||
charm: ops.CharmBase, | ||
tracing_relation_name: str, | ||
*, | ||
ca_relation_name: str | None = None, | ||
ca_data: str | None = None, | ||
): | ||
"""Initialise the tracing service.""" | ||
with tracer.start_as_current_span('ops.tracing.Tracing'): | ||
super().__init__(charm, f'{tracing_relation_name}+{ca_relation_name}') | ||
self.charm = charm | ||
self.tracing_relation_name = tracing_relation_name | ||
self.ca_relation_name = ca_relation_name | ||
self.ca_data = ca_data | ||
|
||
if ca_relation_name is not None and ca_data is not None: | ||
raise ValueError('At most one of ca_relation_name, ca_data is allowed') | ||
|
||
# Validate the arguments manually to raise exceptions with helpful messages. | ||
relation = self.charm.meta.relations.get(tracing_relation_name) | ||
if not relation: | ||
raise ValueError(f'{tracing_relation_name=} is not declared in charm metadata') | ||
|
||
if relation.role is not ops.RelationRole.requires: | ||
raise ValueError( | ||
f"{tracing_relation_name=} {relation.role=} when 'requires' is expected" | ||
) | ||
|
||
if relation.interface_name != 'tracing': | ||
raise ValueError( | ||
f"{tracing_relation_name=} {relation.interface_name=} when 'tracing' is" | ||
f' expected' | ||
) | ||
|
||
for event in ( | ||
self.charm.on.start, | ||
self.charm.on.upgrade_charm, | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Re-check if upgrade-charm is needed; are relation-related events triggered after charm upgrade, just like start and config-changed. |
||
self.charm.on[tracing_relation_name].relation_changed, | ||
self.charm.on[tracing_relation_name].relation_broken, | ||
): | ||
self.framework.observe(event, self._reconcile) | ||
|
||
if ca_relation_name: | ||
relation = self.charm.meta.relations.get(ca_relation_name) | ||
if not relation: | ||
raise ValueError(f'{ca_relation_name=} is not declared in charm metadata') | ||
|
||
if relation.role is not ops.RelationRole.requires: | ||
raise ValueError( | ||
f"{ca_relation_name=} {relation.role=} when 'requires' is expected" | ||
) | ||
if relation.interface_name != 'certificate_transfer': | ||
raise ValueError( | ||
f'{ca_relation_name=} {relation.interface_name=} when' | ||
f" 'certificate_transfer' is expected" | ||
) | ||
|
||
self._certificate_transfer = CertificateTransferRequires(charm, ca_relation_name) | ||
|
||
for event in ( | ||
self._certificate_transfer.on.certificate_set_updated, | ||
self._certificate_transfer.on.certificates_removed, | ||
): | ||
self.framework.observe(event, self._reconcile) | ||
else: | ||
self._certificate_transfer = None | ||
|
||
def _reconcile(self, _event: ops.EventBase): | ||
dst = self._get_destination() | ||
ops.tracing.set_destination(url=dst.url, ca=dst.ca) | ||
|
||
def _get_destination(self) -> Destination: | ||
try: | ||
rel = self.model.get_relation(self.tracing_relation_name) | ||
if not rel: | ||
return Destination(None, None) | ||
|
||
rel.data[self.charm.app]['receivers'] = '["otlp_http"]' | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Must check leadership first. |
||
base_url: str | None = None | ||
app_data = rel.data[rel.app] | ||
# FIXME does the authoritative lib skip over unparsable receivers, or does it bail? | ||
try: | ||
# FIXME the authoritative lib raises if field is not valid JSON: check in tests. | ||
# FIXME the authoritative lib has pydantic raise if subfields are missing. | ||
# We don't want to raise, we'd want to skip, maybe with a warning. | ||
for receiver in json.loads(app_data['receivers']): | ||
proto = receiver['protocol'] | ||
if proto['name'] != 'otlp_http': | ||
continue | ||
if proto['type'] != 'http': | ||
continue | ||
base_url = receiver['url'] | ||
except (KeyError, TypeError, ValueError): | ||
pass | ||
|
||
if not base_url or not isinstance(base_url, str): | ||
return Destination(None, None) | ||
|
||
if not base_url.startswith(('http://', 'https://')): | ||
logger.warning('The base_url=%s must be an HTTP or an HTTPS URL', base_url) | ||
return Destination(None, None) | ||
|
||
url = f'{base_url.rstrip("/")}/v1/traces' | ||
|
||
if url.startswith('http://'): | ||
return Destination(url, None) | ||
|
||
if not self._certificate_transfer: | ||
return Destination(url, self.ca_data) | ||
|
||
ca = self._get_ca() | ||
if not ca: | ||
return Destination(None, None) | ||
|
||
return Destination(url, ca) | ||
except ( | ||
ops.TooManyRelatedAppsError, | ||
AmbiguousRelationUsageError, | ||
ProtocolNotRequestedError, | ||
): | ||
# These should not really happen, as we've set up a single relation | ||
# and requested the protocol explicitly. | ||
logger.exception('Error getting the tracing destination') | ||
return Destination(None, None) | ||
|
||
def _get_ca(self) -> str | None: | ||
if not self.ca_relation_name: | ||
return None | ||
|
||
ca_rel = self.model.get_relation(self.ca_relation_name) | ||
if not ca_rel: | ||
return None | ||
|
||
if not self._certificate_transfer: | ||
return None | ||
|
||
if not self._certificate_transfer.is_ready(ca_rel): | ||
return None | ||
|
||
ca_list = self._certificate_transfer.get_all_certificates(ca_rel.id) | ||
if not ca_list: | ||
return None | ||
|
||
return '\n'.join(sorted(ca_list)) |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -49,6 +49,7 @@ dev = [ | |
] | ||
testing = [ | ||
"pytest ~= 8.4", | ||
"ops[testing]", | ||
] | ||
|
||
[build-system] | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,103 @@ | ||
# Copyright 2025 Canonical Ltd. | ||
# | ||
# Licensed under the Apache License, Version 2.0 (the "License"); | ||
# you may not use this file except in compliance with the License. | ||
# You may obtain a copy of the License at | ||
# | ||
# http://www.apache.org/licenses/LICENSE-2.0 | ||
# | ||
# Unless required by applicable law or agreed to in writing, software | ||
# distributed under the License is distributed on an "AS IS" BASIS, | ||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
# See the License for the specific language governing permissions and | ||
# limitations under the License. | ||
|
||
from __future__ import annotations | ||
|
||
import json | ||
from typing import Literal, Type, TypeAlias | ||
from unittest.mock import Mock | ||
|
||
import ops | ||
import ops.testing | ||
import pytest | ||
|
||
_pydantic = pytest.importorskip('pydantic') | ||
|
||
pytestmark = pytest.mark.filterwarnings('ignore::pydantic.PydanticDeprecatedSince20') | ||
|
||
|
||
def test_charm_runs(sample_charm: Type[ops.CharmBase]): | ||
ctx = ops.testing.Context(sample_charm) | ||
state_in = ops.testing.State() | ||
state_out = ctx.run(ctx.on.start(), state_in) | ||
assert isinstance(state_out.unit_status, ops.ActiveStatus) | ||
assert 'ops.main' in [span.name for span in ctx.trace_data] | ||
assert 'my collect status' in [span.name for span in ctx.trace_data] | ||
|
||
|
||
@pytest.fixture | ||
def mock_destination(monkeypatch: pytest.MonkeyPatch) -> Mock: | ||
rv = Mock() | ||
monkeypatch.setattr(ops.tracing, 'set_destination', rv) | ||
return rv | ||
|
||
|
||
RECEIVER: TypeAlias = dict[str, int | str | dict[str, str]] | ||
RECEIVERS: TypeAlias = list[RECEIVER] | ||
GOOD_PROTOCOL: RECEIVER = {'protocol': {'name': 'otlp_http', 'type': 'http'}} | ||
GOOD_HTTP_URL: RECEIVER = {'url': 'http://tracing.example:4318/'} | ||
GOOD_HTTPS_URL: RECEIVER = {'url': 'https://tls.example:4318/'} | ||
GRPC_RECEIVER: RECEIVER = {'protocol': {'name': 'otlp_grpc', 'type': 'grpc'}, 'url': 'grpc://'} | ||
EVENTS = ['start', 'upgrade_charm', 'relation_changed', 'relation_broken'] | ||
|
||
|
||
@pytest.mark.parametrize('extra_receiver_fields', [{}, {'foo': 'bar'}]) | ||
@pytest.mark.parametrize('extra_receiver', [None, GRPC_RECEIVER]) | ||
@pytest.mark.parametrize( | ||
'extra_databag_fields', [{}, {'foo': 'not-json'}, {'foo': '"json-str"'}, {'version': '1'}] | ||
) | ||
@pytest.mark.parametrize( | ||
'receiver', | ||
[ | ||
{**GOOD_PROTOCOL, **GOOD_HTTP_URL}, | ||
{**GOOD_PROTOCOL, **GOOD_HTTPS_URL}, | ||
], | ||
) | ||
@pytest.mark.parametrize('event', EVENTS) | ||
@pytest.mark.parametrize('implementation', ['old', 'new']) | ||
def test_foo( | ||
sample_charm: Type[ops.CharmBase], | ||
mock_destination: Mock, | ||
ca_relation: ops.testing.Relation, | ||
monkeypatch: pytest.MonkeyPatch, | ||
implementation: Literal['old', 'new'], | ||
event: str, | ||
receiver: RECEIVER, | ||
extra_databag_fields: dict[str, str], | ||
extra_receiver: RECEIVER | None, | ||
extra_receiver_fields: RECEIVER, | ||
): | ||
url = f'{receiver["url"].strip("/")}/v1/traces' # type: ignore | ||
ca = 'FIRST\nSECOND' if url.startswith('https') else None | ||
receiver = {**receiver, **extra_receiver_fields} | ||
receivers = [receiver] if extra_receiver is None else [receiver, extra_receiver] | ||
databag = {'receivers': json.dumps(receivers), **extra_databag_fields} | ||
charm_tracing_relation = ops.testing.Relation('charm-tracing', id=0, remote_app_data=databag) | ||
|
||
assert implementation in ('old', 'new') | ||
|
||
if implementation == 'new': | ||
monkeypatch.setattr('ops.tracing.Tracing', ops.tracing.Tracing2) | ||
|
||
ctx = ops.testing.Context(sample_charm) | ||
state = ops.testing.State(relations={charm_tracing_relation, ca_relation}, leader=True) | ||
|
||
event_args = [charm_tracing_relation] if event.startswith('relation') else [] | ||
state = ctx.run(getattr(ctx.on, event)(*event_args), state) | ||
|
||
if event == 'relation_broken': | ||
mock_destination.assert_called_with(url=None, ca=None) | ||
else: | ||
assert state.get_relation(0).local_app_data == {'receivers': '["otlp_http"]'} | ||
mock_destination.assert_called_with(url=url, ca=ca) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Re-check if start event is neeed, even with bundles.