Skip to content

Commit 15d91c7

Browse files
committed
feat: ops[tracing]
1 parent 2806558 commit 15d91c7

30 files changed

+1636
-484
lines changed

docs/howto/index.md

+1
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ Get started with charm testing <get-started-with-charm-testing>
2424
Write unit tests for a charm <write-scenario-tests-for-a-charm>
2525
Write integration tests for a charm <write-integration-tests-for-a-charm>
2626
Write legacy unit tests for a charm <write-unit-tests-for-a-charm>
27+
Trace the charm code <trace-the-charm-code>
2728
Turn a hooks-based charm into an ops charm <turn-a-hooks-based-charm-into-an-ops-charm>
2829
2930
```

docs/howto/trace-the-charm-code.md

+36
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
(trace-the-charm-code)=
2+
# Trace the charm code
3+
4+
## Tracing from scratch
5+
6+
FIXME: write this up
7+
8+
- depend on `ops[tracing]`
9+
- remove charm\_tracing charm lib, if it's installed
10+
- observe the `SetupTracingEvent`
11+
12+
```py
13+
class YourCharm(ops.CharmBase):
14+
def __init__(self, framework: ops.Framework):
15+
super().__init__(framework)
16+
self.framework.observe(self.on.setup_tracing, self._on_setup_tracing)
17+
...
18+
19+
def _on_setup_tracing(self, event: ops.SetupTracingEvent) -> None:
20+
# FIXME must get this from some relation
21+
event.set_destination(url='http://localhost:4318/v1/traces')
22+
```
23+
24+
## Migrating from charm\_tracing
25+
26+
- depend on `ops[tracing]`
27+
- remove charm\_tracing charm lib, if it's installed
28+
- remove `@trace_charm` decorator
29+
- observe the `SetupTracingEvent`
30+
- instrument key functions in the charm
31+
32+
NOTE: charm\_tracing auto-instruments all public function on the class. `ops[tracing]` doesn't do that.
33+
34+
```py
35+
# FIXME example
36+
```

docs/reference/index.md

+1
Original file line numberDiff line numberDiff line change
@@ -9,5 +9,6 @@ ops
99
pebble
1010
ops-testing
1111
ops-testing-harness
12+
ops-tracing
1213
```
1314

docs/reference/ops-tracing.rst

+14
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
.. _ops_tracing:
2+
3+
`ops[tracing]`, telemetry
4+
=========================
5+
6+
FIXME: write this up.
7+
8+
Open Telemetry resource attributes.
9+
10+
- ``service.namespace`` the UUID of the Juju model.
11+
- ``service.namespace.name`` the name of the Juju model.
12+
- ``service.name`` the application name, like ``user_db``.
13+
- ``service.instance.id`` the unit number, like ``0``.
14+
- ``service.charm`` the charm class name, like ``DbCharm``.

docs/requirements.txt

+43-3
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
# This file is autogenerated by pip-compile with Python 3.11
33
# by the following command:
44
#
5-
# pip-compile --extra=docs --output-file=docs/requirements.txt pyproject.toml
5+
# pip-compile --extra=docs,tracing --output-file=docs/requirements.txt pyproject.toml
66
#
77
alabaster==1.0.0
88
# via sphinx
@@ -29,6 +29,10 @@ click==8.1.8
2929
# via uvicorn
3030
colorama==0.4.6
3131
# via sphinx-autobuild
32+
deprecated==1.2.15
33+
# via
34+
# opentelemetry-api
35+
# opentelemetry-semantic-conventions
3236
docutils==0.21.2
3337
# via
3438
# canonical-sphinx-extensions
@@ -51,6 +55,10 @@ idna==3.10
5155
# requests
5256
imagesize==1.4.1
5357
# via sphinx
58+
importlib-metadata==8.5.0
59+
# via
60+
# opentelemetry-api
61+
# ops (pyproject.toml)
5462
jinja2==3.1.5
5563
# via
5664
# myst-parser
@@ -73,8 +81,32 @@ mdurl==0.1.2
7381
# via markdown-it-py
7482
myst-parser==4.0.0
7583
# via ops (pyproject.toml)
84+
opentelemetry-api==1.30.0
85+
# via
86+
# opentelemetry-instrumentation
87+
# opentelemetry-instrumentation-urllib
88+
# opentelemetry-sdk
89+
# opentelemetry-semantic-conventions
90+
# ops (pyproject.toml)
91+
opentelemetry-instrumentation==0.51b0
92+
# via opentelemetry-instrumentation-urllib
93+
opentelemetry-instrumentation-urllib==0.51b0
94+
# via ops (pyproject.toml)
95+
opentelemetry-sdk==1.30.0
96+
# via ops (pyproject.toml)
97+
opentelemetry-semantic-conventions==0.51b0
98+
# via
99+
# opentelemetry-instrumentation
100+
# opentelemetry-instrumentation-urllib
101+
# opentelemetry-sdk
102+
opentelemetry-util-http==0.51b0
103+
# via opentelemetry-instrumentation-urllib
104+
otlp-json==0.9.1
105+
# via ops (pyproject.toml)
76106
packaging==24.2
77-
# via sphinx
107+
# via
108+
# opentelemetry-instrumentation
109+
# sphinx
78110
pygments==2.19.1
79111
# via
80112
# furo
@@ -148,7 +180,9 @@ sphinxext-opengraph==0.9.1
148180
starlette==0.45.2
149181
# via sphinx-autobuild
150182
typing-extensions==4.12.2
151-
# via anyio
183+
# via
184+
# anyio
185+
# opentelemetry-sdk
152186
uc-micro-py==1.0.3
153187
# via linkify-it-py
154188
urllib3==2.3.0
@@ -165,4 +199,10 @@ websocket-client==1.8.0
165199
# via ops (pyproject.toml)
166200
websockets==14.1
167201
# via sphinx-autobuild
202+
wrapt==1.17.2
203+
# via
204+
# deprecated
205+
# opentelemetry-instrumentation
206+
zipp==3.21.0
207+
# via importlib-metadata
168208
./testing/

dont-merge/fake-charm.py

+96
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
#!/usr/bin/env python
2+
# Copyright 2025 Canonical Ltd.
3+
#
4+
# Licensed under the Apache License, Version 2.0 (the "License");
5+
# you may not use this file except in compliance with the License.
6+
# You may obtain a copy of the License at
7+
#
8+
# http://www.apache.org/licenses/LICENSE-2.0
9+
#
10+
# Unless required by applicable law or agreed to in writing, software
11+
# distributed under the License is distributed on an "AS IS" BASIS,
12+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
# See the License for the specific language governing permissions and
14+
# limitations under the License.
15+
"""FIXME dummy_load docstring."""
16+
17+
from __future__ import annotations
18+
19+
import time
20+
21+
import opentelemetry.trace
22+
23+
import ops
24+
25+
tracer = opentelemetry.trace.get_tracer(__name__)
26+
27+
28+
class DatabaseReadyEvent(ops.charm.EventBase):
29+
"""Event representing that the database is ready."""
30+
31+
32+
class DatabaseRequirerEvents(ops.framework.ObjectEvents):
33+
"""Container for Database Requirer events."""
34+
35+
ready = ops.charm.EventSource(DatabaseReadyEvent)
36+
37+
38+
class DatabaseRequirer(ops.framework.Object):
39+
"""Dummy docstring."""
40+
41+
on = DatabaseRequirerEvents() # type: ignore
42+
43+
def __init__(self, charm: ops.CharmBase):
44+
"""Dummy docstring."""
45+
super().__init__(charm, 'foo')
46+
self.framework.observe(charm.on.start, self._on_db_changed)
47+
48+
def _on_db_changed(self, event: ops.StartEvent) -> None:
49+
"""Dummy docstring."""
50+
self.on.ready.emit()
51+
52+
53+
class FakeCharm(ops.CharmBase):
54+
"""Dummy docstring."""
55+
56+
def __init__(self, framework: ops.Framework):
57+
"""Dummy docstring."""
58+
super().__init__(framework)
59+
self.framework.observe(self.on.setup_tracing, self._on_setup_tracing)
60+
self.framework.observe(self.on.start, self._on_start)
61+
self.framework.observe(self.on.collect_app_status, self._on_collect_app_status)
62+
self.db_requirer = DatabaseRequirer(self)
63+
self.framework.observe(self.db_requirer.on.ready, self._on_db_ready)
64+
65+
def _on_setup_tracing(self, event: ops.SetupTracingEvent) -> None:
66+
"""Dummy docstring."""
67+
self.dummy_load(event)
68+
event.set_destination(url='http://localhost:4318/v1/traces')
69+
70+
def _on_start(self, event: ops.StartEvent) -> None:
71+
"""Dummy docstring."""
72+
self.dummy_load(event)
73+
event.defer()
74+
75+
def _on_db_ready(self, event: DatabaseReadyEvent) -> None:
76+
self.dummy_load(event)
77+
78+
def _on_collect_app_status(self, event: ops.CollectStatusEvent) -> None:
79+
"""Dummy docstring."""
80+
self.dummy_load(event)
81+
event.add_status(ops.ActiveStatus('app seems ready'))
82+
83+
def _on_collect_unit_status(self, event: ops.CollectStatusEvent) -> None:
84+
"""Dummy docstring."""
85+
self.dummy_load(event)
86+
event.add_status(ops.ActiveStatus('unit ready'))
87+
88+
@tracer.start_as_current_span('FakeCharm.dummy_load')
89+
def dummy_load(self, event: ops.EventBase, duration: float = 0.0001) -> None:
90+
"""Dummy docstring."""
91+
print(event)
92+
time.sleep(duration)
93+
94+
95+
if __name__ == '__main__':
96+
ops.main(FakeCharm)

dont-merge/metadata.yaml

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
name: testmetest

dont-merge/readme.md

+36
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
### Usage
2+
3+
Run Jaeger all-in-one to collect traces on your machine:
4+
5+
```command
6+
> docker run --rm --name jaeger \
7+
-p 16686:16686 \
8+
-p 4317:4317 \
9+
-p 4318:4318 \
10+
-p 5778:5778 \
11+
-p 9411:9411 \
12+
jaegertracing/jaeger:2.2.0
13+
```
14+
15+
After which, you should be able to:
16+
- generate some traces (see below)
17+
- open `http://<ip address>:16686/` in your browser, perhaps http://localhost:16686/
18+
- select the correct **Service** (application name)
19+
- click Search at the bottom of the form
20+
21+
Notes:
22+
- the Jaeger container keeps traces in memory, data is lost when container is restarted.
23+
- a Service can only be selected in the UI if some data for that service has been sent.
24+
25+
### Instrument a charm
26+
27+
A k8s charm can access the host by its IP address, thus:
28+
29+
```py
30+
def __init__(self, framework: ops.Framework):
31+
...
32+
self.framework.observe(self.on.setup_tracing, self._on_setup_tracing)
33+
34+
def _on_setup_tracing(self, event: ops.SetupTracingEvent) -> None:
35+
event.set_destination(url='http://<ip address>:4318/v1/traces') # you machine ip address
36+
```

ops/__init__.py

+4
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@
9999
'SecretExpiredEvent',
100100
'SecretRemoveEvent',
101101
'SecretRotateEvent',
102+
'SetupTracingEvent',
102103
'StartEvent',
103104
'StopEvent',
104105
'StorageAttachedEvent',
@@ -187,6 +188,8 @@
187188
# isort:skip_file
188189
from typing import Optional, Type
189190

191+
from . import _aaa_venv_workaround as _aaa_venv_workaround
192+
190193
# Import pebble explicitly. It's the one module we don't import names from below.
191194
from . import pebble
192195

@@ -243,6 +246,7 @@
243246
SecretExpiredEvent,
244247
SecretRemoveEvent,
245248
SecretRotateEvent,
249+
SetupTracingEvent,
246250
StartEvent,
247251
StopEvent,
248252
StorageAttachedEvent,

ops/_aaa_venv_workaround.py

+69
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
# Copyright 2025 Canonical Ltd.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License"); you may not use this
4+
# file except in compliance with the License. You may obtain a copy of the License at
5+
#
6+
# http://www.apache.org/licenses/LICENSE-2.0
7+
#
8+
# Unless required by applicable law or agreed to in writing, software distributed under
9+
# the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF
10+
# ANY KIND, either express or implied. See the License for the specific language
11+
# governing permissions and limitations under the License.
12+
"""Workarounds for various Juju bugs."""
13+
14+
from __future__ import annotations
15+
16+
import logging
17+
import os
18+
import shutil
19+
from collections import defaultdict
20+
from typing import Any
21+
22+
from importlib_metadata import distributions # type: ignore
23+
24+
logger = logging.getLogger(__name__)
25+
26+
27+
def remove_stale_otel_sdk_packages() -> None:
28+
"""Remove stale opentelemetry sdk packages from the charm's Python venv.
29+
30+
Charmcraft doesn't record empty directories in the charm (zip) file.
31+
Juju creates directories on demand when a contained file is unpacked.
32+
Juju removes what it has installed before the upgrade is unpacked.
33+
Juju prior to 3.5.4 left unrecorded, stale directories.
34+
35+
See https://github.com/canonical/grafana-agent-operator/issues/146
36+
and https://bugs.launchpad.net/juju/+bug/2058335
37+
38+
This only has an effect if executed on an upgrade-charm event.
39+
"""
40+
if os.getenv('JUJU_DISPATCH_PATH') != 'hooks/upgrade-charm':
41+
return
42+
43+
logger.debug('Applying _remove_stale_otel_sdk_packages patch on charm upgrade')
44+
# group by name all distributions starting with "opentelemetry_"
45+
otel_distributions: dict[str, list[Any]] = defaultdict(list)
46+
for distribution in distributions():
47+
name = distribution._normalized_name
48+
if name.startswith('opentelemetry_'):
49+
otel_distributions[name].append(distribution)
50+
51+
logger.debug(f'Found {len(otel_distributions)} opentelemetry distributions')
52+
53+
# If we have multiple distributions with the same name, remove any that have 0
54+
# associated files
55+
for name, distributions_ in otel_distributions.items():
56+
if len(distributions_) <= 1:
57+
continue
58+
59+
logger.debug(f'Package {name} has multiple ({len(distributions_)}) distributions.')
60+
for distribution in distributions_:
61+
if not distribution.files: # Not None or empty list
62+
path = distribution._path
63+
logger.info(f'Removing empty distribution of {name} at {path}.')
64+
shutil.rmtree(path)
65+
66+
logger.debug('Successfully applied _remove_stale_otel_sdk_packages patch. ')
67+
68+
69+
remove_stale_otel_sdk_packages()

0 commit comments

Comments
 (0)