Skip to content

Commit 4cc1209

Browse files
committed
feat: ops[tracing] with first-party charm lib
1 parent 7b80057 commit 4cc1209

39 files changed

+3808
-407
lines changed
+54
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
on:
2+
push:
3+
branches: [feat-otel-ops-tracing-lib]
4+
#pull_request:
5+
#branches: [main]
6+
7+
jobs:
8+
ci:
9+
runs-on: ubuntu-latest
10+
strategy:
11+
fail-fast: false
12+
matrix:
13+
python-version: [3.8, 3.9, "3.10", 3.11, 3.12, 3.13, 3.13t, 3.14]
14+
dep-versions: [--frozen, "--resolution lowest", "--resolution highest"]
15+
steps:
16+
- uses: actions/checkout@v4
17+
- uses: astral-sh/setup-uv@v5
18+
- run: |
19+
cd tracing
20+
uv python install ${{ matrix.python-version }}
21+
uv sync --all-groups ${{ matrix.dep-versions }}
22+
uv pip install .
23+
uv pip freeze
24+
uv run pytest
25+
uv run pyright
26+
uv run ruff check
27+
uv build
28+
29+
release:
30+
# needs: ci
31+
runs-on: ubuntu-latest
32+
environment: release
33+
permissions:
34+
id-token: write
35+
attestations: write
36+
steps:
37+
- uses: actions/checkout@v4
38+
- uses: astral-sh/setup-uv@v5
39+
- run: |
40+
cd tracing
41+
uv build --sdist --wheel
42+
rm -vf dist/.gitignore # https://github.com/astral-sh/uv/issues/11652
43+
- uses: pypa/gh-action-pypi-publish@release/v1
44+
with:
45+
packages-dir: ./tracing/dist/
46+
repository-url: https://test.pypi.org/legacy/
47+
skip-existing: true
48+
verbose: true
49+
- run: rm -f tracing/dist/*.attestation # should I parametrise release instead?
50+
- uses: pypa/gh-action-pypi-publish@release/v1
51+
with:
52+
packages-dir: ./tracing/dist/
53+
skip-existing: true
54+
verbose: true

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

+21
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
(trace-the-charm-code)=
2+
# Trace the charm code
3+
4+
## Tracing from scratch
5+
6+
FIXME: copy from tracing/api.py
7+
8+
## Migrating from charm\_tracing
9+
10+
- depend on `ops[tracing]`
11+
- remove direct dependencies on `opentelemetry-api, -sdk, etc.`
12+
- remove charm\_tracing charm lib, if it's installed
13+
- remove `@trace_charm` decorator
14+
- include `ops._tracing.Tracing()` in your charm's `__init__`
15+
- instrument key functions in the charm
16+
17+
NOTE: charm\_tracing auto-instruments all public function on the class. `ops[tracing]` doesn't do that.
18+
19+
```py
20+
# FIXME example
21+
```

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

+49-2
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,12 @@
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
9+
annotated-types==0.7.0
10+
# via pydantic
911
anyio==4.8.0
1012
# via
1113
# starlette
@@ -29,6 +31,10 @@ click==8.1.8
2931
# via uvicorn
3032
colorama==0.4.6
3133
# via sphinx-autobuild
34+
deprecated==1.2.15
35+
# via
36+
# opentelemetry-api
37+
# opentelemetry-semantic-conventions
3238
docutils==0.21.2
3339
# via
3440
# canonical-sphinx-extensions
@@ -51,6 +57,10 @@ idna==3.10
5157
# requests
5258
imagesize==1.4.1
5359
# via sphinx
60+
importlib-metadata==8.5.0
61+
# via
62+
# opentelemetry-api
63+
# ops (pyproject.toml)
5464
jinja2==3.1.6
5565
# via
5666
# myst-parser
@@ -73,8 +83,36 @@ mdurl==0.1.2
7383
# via markdown-it-py
7484
myst-parser==4.0.1
7585
# via ops (pyproject.toml)
86+
opentelemetry-api==1.30.0
87+
# via
88+
# opentelemetry-instrumentation
89+
# opentelemetry-instrumentation-urllib
90+
# opentelemetry-sdk
91+
# opentelemetry-semantic-conventions
92+
# ops (pyproject.toml)
93+
opentelemetry-instrumentation==0.51b0
94+
# via opentelemetry-instrumentation-urllib
95+
opentelemetry-instrumentation-urllib==0.51b0
96+
# via ops (pyproject.toml)
97+
opentelemetry-sdk==1.30.0
98+
# via ops (pyproject.toml)
99+
opentelemetry-semantic-conventions==0.51b0
100+
# via
101+
# opentelemetry-instrumentation
102+
# opentelemetry-instrumentation-urllib
103+
# opentelemetry-sdk
104+
opentelemetry-util-http==0.51b0
105+
# via opentelemetry-instrumentation-urllib
106+
otlp-json==0.9.7
107+
# via ops (pyproject.toml)
76108
packaging==24.2
77-
# via sphinx
109+
# via
110+
# opentelemetry-instrumentation
111+
# sphinx
112+
pydantic==2.10.6
113+
# via ops (pyproject.toml)
114+
pydantic-core==2.27.2
115+
# via pydantic
78116
pygments==2.19.1
79117
# via
80118
# furo
@@ -151,6 +189,9 @@ typing-extensions==4.12.2
151189
# via
152190
# anyio
153191
# beautifulsoup4
192+
# opentelemetry-sdk
193+
# pydantic
194+
# pydantic-core
154195
uc-micro-py==1.0.3
155196
# via linkify-it-py
156197
urllib3==2.3.0
@@ -167,4 +208,10 @@ websocket-client==1.8.0
167208
# via ops (pyproject.toml)
168209
websockets==15.0.1
169210
# via sphinx-autobuild
211+
wrapt==1.17.2
212+
# via
213+
# deprecated
214+
# opentelemetry-instrumentation
215+
zipp==3.21.0
216+
# via importlib-metadata
170217
./testing/

ops/__init__.py

+9
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@
5555
__all__ = [ # noqa: RUF022 `__all__` is not sorted
5656
'__version__',
5757
'main',
58+
'tracing',
5859
'pebble',
5960
# From charm.py
6061
'ActionEvent',
@@ -187,6 +188,9 @@
187188
# isort:skip_file
188189
from typing import Optional, Type
189190

191+
# FIXME: need to decide on this at a stand-up
192+
from . import _aaa_venv_workaround as _aaa_venv_workaround
193+
190194
# Import pebble explicitly. It's the one module we don't import names from below.
191195
from . import pebble
192196

@@ -335,6 +339,11 @@
335339

336340
from .version import version as __version__
337341

342+
try:
343+
import ops_tracing as tracing
344+
except ImportError:
345+
tracing = None
346+
338347

339348
class _Main:
340349
def __call__(

ops/_aaa_venv_workaround.py

+76
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
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+
https://github.com/juju/charm/pull/435
15+
16+
> It is important to note that this change will only ensure the proper cleanup of files
17+
> for charms that are newly deployed, as charms that are already deployed have their
18+
> manifests written to the manifest files on disk.
19+
"""
20+
21+
from __future__ import annotations
22+
23+
import logging
24+
import os
25+
import shutil
26+
from collections import defaultdict
27+
from typing import Any
28+
29+
from importlib_metadata import distributions # type: ignore
30+
31+
logger = logging.getLogger(__name__)
32+
33+
34+
def remove_stale_otel_sdk_packages() -> None:
35+
"""Remove stale opentelemetry sdk packages from the charm's Python venv.
36+
37+
Charmcraft doesn't record empty directories in the charm (zip) file.
38+
Juju creates directories on demand when a contained file is unpacked.
39+
Juju removes what it has installed before the upgrade is unpacked.
40+
Juju prior to 3.5.4 left unrecorded, stale directories.
41+
42+
See https://github.com/canonical/grafana-agent-operator/issues/146
43+
and https://bugs.launchpad.net/juju/+bug/2058335
44+
45+
This only has an effect if executed on an upgrade-charm event.
46+
"""
47+
if os.getenv('JUJU_DISPATCH_PATH') != 'hooks/upgrade-charm':
48+
return
49+
50+
logger.debug('Applying _remove_stale_otel_sdk_packages patch on charm upgrade')
51+
# group by name all distributions starting with "opentelemetry_"
52+
otel_distributions: dict[str, list[Any]] = defaultdict(list)
53+
for distribution in distributions():
54+
name = distribution._normalized_name
55+
if name.startswith('opentelemetry_'):
56+
otel_distributions[name].append(distribution)
57+
58+
logger.debug(f'Found {len(otel_distributions)} opentelemetry distributions')
59+
60+
# If we have multiple distributions with the same name, remove any that have 0
61+
# associated files
62+
for name, distributions_ in otel_distributions.items():
63+
if len(distributions_) <= 1:
64+
continue
65+
66+
logger.debug(f'Package {name} has multiple ({len(distributions_)}) distributions.')
67+
for distribution in distributions_:
68+
if not distribution.files: # Not None or empty list
69+
path = distribution._path
70+
logger.info(f'Removing empty distribution of {name} at {path}.')
71+
shutil.rmtree(path)
72+
73+
logger.debug('Successfully applied _remove_stale_otel_sdk_packages patch. ')
74+
75+
76+
remove_stale_otel_sdk_packages()

ops/_main.py

+21-8
Original file line numberDiff line numberDiff line change
@@ -20,20 +20,20 @@
2020
import subprocess
2121
import sys
2222
import warnings
23+
from contextlib import nullcontext
2324
from pathlib import Path
2425
from typing import Any, Dict, List, Optional, Tuple, Type, Union, cast
2526

2627
from . import charm as _charm
2728
from . import framework as _framework
2829
from . import model as _model
2930
from . import storage as _storage
30-
from . import version as _version
3131
from .jujucontext import _JujuContext
3232
from .log import setup_root_logging
33+
from .version import tracer, version
3334

3435
CHARM_STATE_FILE = '.unit-state.db'
3536

36-
3737
logger = logging.getLogger()
3838

3939

@@ -212,6 +212,8 @@ class _Dispatcher:
212212
213213
"""
214214

215+
event_name: str
216+
215217
def __init__(self, charm_dir: Path, juju_context: _JujuContext):
216218
self._juju_context = juju_context
217219
self._charm_dir = charm_dir
@@ -421,7 +423,7 @@ def _setup_root_logging(self):
421423
self._model_backend, debug=self._juju_context.debug, exc_stderr=handling_action
422424
)
423425

424-
logger.debug('ops %s up and running.', _version.version)
426+
logger.debug('ops %s up and running.', version)
425427

426428
def _make_storage(self, dispatcher: _Dispatcher):
427429
charm_state_path = self._charm_root / self._charm_state_path
@@ -552,9 +554,20 @@ def main(charm_class: Type[_charm.CharmBase], use_juju_for_storage: Optional[boo
552554
553555
See `ops.main() <#ops-main-entry-point>`_ for details.
554556
"""
555-
try:
556-
manager = _Manager(charm_class, use_juju_for_storage=use_juju_for_storage)
557+
from . import tracing # break circular import
557558

558-
manager.run()
559-
except _Abort as e:
560-
sys.exit(e.exit_code)
559+
juju_context = _JujuContext.from_dict(os.environ)
560+
tracing_manager = (
561+
tracing.setup(juju_context, charm_class.__name__) if tracing else nullcontext()
562+
)
563+
with tracing_manager:
564+
try:
565+
with tracer.start_as_current_span('ops.main'):
566+
manager = _Manager(
567+
charm_class,
568+
use_juju_for_storage=use_juju_for_storage,
569+
juju_context=juju_context,
570+
)
571+
manager.run()
572+
except _Abort as e:
573+
sys.exit(e.exit_code)

0 commit comments

Comments
 (0)