Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion tracing/ops_tracing/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,14 +73,15 @@ def __init__(self, framework: ops.Framework):
available when your Python project depends on ``ops[tracing]``.
"""

from ._api import Tracing
from ._api import Tracing, Tracing2
from ._backend import mark_observed as _mark_observed
from ._backend import set_destination
from ._backend import setup as _setup
from ._backend import shutdown as _shutdown

__all__ = [
'Tracing',
'Tracing2',
'_mark_observed',
'_setup',
'_shutdown',
Expand Down
197 changes: 197 additions & 0 deletions tracing/ops_tracing/_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@

from __future__ import annotations

import json
import logging

import opentelemetry.trace
Expand Down Expand Up @@ -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,
Copy link
Contributor Author

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.

self.charm.on.upgrade_charm,
Copy link
Contributor Author

Choose a reason for hiding this comment

The 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"]'
Copy link
Contributor Author

Choose a reason for hiding this comment

The 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))
1 change: 1 addition & 0 deletions tracing/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ dev = [
]
testing = [
"pytest ~= 8.4",
"ops[testing]",
]

[build-system]
Expand Down
103 changes: 103 additions & 0 deletions tracing/test/test_equivalence.py
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)
Loading
Loading