From f5550d42485cb4b6615b17703c8df3c5bbd1e04d Mon Sep 17 00:00:00 2001 From: Josh Taillon Date: Tue, 1 Sep 2026 18:04:24 -0600 Subject: [PATCH] Add setup-span helpers for product setup `commons_span()` opens a span that is current for the duration of a `with` block, so spans opened inside it become its children. Attributes known up front are passed at creation, because samplers see only those and Connect snapshots a span's attributes when it opens; the yielded span takes values the covered work produces. Setup spans cover building a data source or constructing an agent rather than conversation content, so they are not gated behind an agent's `log` argument and open on every agent. That is affordable because the OpenTelemetry API is inert until an SDK provider is configured, which a test pins by running a fresh interpreter and asserting the span does not record. The API is imported unguarded, so commons now requires `opentelemetry-api` outright rather than receiving it through chatlas, which is free to drop it in any release the version floor allows. Only the SDK and the exporters stay optional in the `tracing` extra. A packaging test pins both halves of that. The tracer is resolved once at import. With no provider configured that is a proxy which re-checks the global provider each time a span opens, so a provider installed later still takes effect. Part of kata bvcv (M7). Closes kata wbx8. --- pkg-py/pyproject.toml | 3 + pkg-py/src/commons/_tracing.py | 44 ++++++++++ pkg-py/tests/test_packaging.py | 19 +++- pkg-py/tests/test_tracing.py | 155 +++++++++++++++++++++++++++++++++ pkg-py/uv.lock | 2 + 5 files changed, 222 insertions(+), 1 deletion(-) create mode 100644 pkg-py/src/commons/_tracing.py create mode 100644 pkg-py/tests/test_tracing.py diff --git a/pkg-py/pyproject.toml b/pkg-py/pyproject.toml index 5d178596..6ea64a98 100644 --- a/pkg-py/pyproject.toml +++ b/pkg-py/pyproject.toml @@ -10,6 +10,9 @@ license-files = ["LICENSE.md"] classifiers = ["Development Status :: 2 - Pre-Alpha"] dependencies = [ "chatlas>=0.22.0", + # The API only. The SDK, which turns spans into exported data, is optional + # and lives in the `tracing` extra. + "opentelemetry-api>=1.0", "pydantic>=2", "duckdb>=1.0", "raghilda>=0.2", diff --git a/pkg-py/src/commons/_tracing.py b/pkg-py/src/commons/_tracing.py new file mode 100644 index 00000000..292c45dd --- /dev/null +++ b/pkg-py/src/commons/_tracing.py @@ -0,0 +1,44 @@ +"""OpenTelemetry spans for commons. + +Setup spans cover product setup, building a data source or constructing an +agent, rather than conversation content, so they are not gated behind an +agent's ``log`` argument. That is affordable because the OpenTelemetry API is +inert until an SDK provider is configured: without one, a span is a +non-recording stand-in and costs almost nothing. commons therefore requires +the API outright and leaves the SDK and the exporters to the ``tracing`` +extra. ``pkg-r/R/tracing.R`` holds the R counterparts. +""" + +from __future__ import annotations + +from collections.abc import Iterator, Mapping +from contextlib import contextmanager +from typing import Any + +from opentelemetry import trace +from opentelemetry.trace import Span + +__all__ = ["TRACER_NAME", "Span", "commons_span"] + +# Identifies commons as the emitter, alongside chatlas' own spans. R uses +# `co.posit.r-package.commons`. +TRACER_NAME = "co.posit.python-package.commons" + +# Resolved once. With no provider configured this is a proxy that re-checks the +# global provider each time a span opens, so a provider installed later still +# takes effect. +_TRACER = trace.get_tracer(TRACER_NAME) + + +@contextmanager +def commons_span( + name: str, attributes: Mapping[str, Any] | None = None +) -> Iterator[Span]: + """Open a span, current for the duration of the block, and end it on exit. + + Set what is already known through ``attributes``: samplers see only those, + and Connect snapshots a span's attributes when it opens. Values that the + covered work produces, a row count for instance, go on the yielded span. + """ + with _TRACER.start_as_current_span(name, attributes=attributes) as span: + yield span diff --git a/pkg-py/tests/test_packaging.py b/pkg-py/tests/test_packaging.py index e74193fe..12333dea 100644 --- a/pkg-py/tests/test_packaging.py +++ b/pkg-py/tests/test_packaging.py @@ -7,7 +7,7 @@ """ import importlib.resources -from importlib.metadata import metadata, version +from importlib.metadata import metadata, requires, version import commons @@ -25,3 +25,20 @@ def test_package_ships_type_information() -> None: # py.typed is what makes the annotations visible to consumers' type # checkers; a missing marker degrades silently to Any at the boundary. assert (importlib.resources.files("commons") / "py.typed").is_file() + + +def test_the_opentelemetry_api_is_a_declared_dependency() -> None: + # `commons._tracing` imports `opentelemetry.trace` unguarded, so the API + # package has to be required unconditionally rather than arrive through + # chatlas. The SDK is genuinely optional and stays in the `tracing` extra. + unconditional = [ + requirement + for requirement in requires("commons") or [] + if ";" not in requirement + ] + assert any( + requirement.startswith("opentelemetry-api") for requirement in unconditional + ) + assert not any( + requirement.startswith("opentelemetry-sdk") for requirement in unconditional + ) diff --git a/pkg-py/tests/test_tracing.py b/pkg-py/tests/test_tracing.py new file mode 100644 index 00000000..17438d80 --- /dev/null +++ b/pkg-py/tests/test_tracing.py @@ -0,0 +1,155 @@ +"""Setup spans: what commons records, and what happens without OpenTelemetry. + +The span and attribute names used here are examples that exercise the helper. +Nothing emits them yet: the names commons really records arrive with the +modules that record them (kata w0t2), and the span contract R reads a +trajectory back through is pinned by a shared fixture (kata 4frb). +""" + +from __future__ import annotations + +import subprocess +import sys +import textwrap +from collections.abc import Iterator + +import pytest + +# The SDK ships in the `tracing` extra. CI installs every extra, so these run +# there; a plain dev environment skips them rather than failing to import. +pytest.importorskip("opentelemetry.sdk") + +from opentelemetry import trace +from opentelemetry.sdk.trace import ReadableSpan, TracerProvider +from opentelemetry.sdk.trace.export import SimpleSpanProcessor +from opentelemetry.sdk.trace.export.in_memory_span_exporter import ( + InMemorySpanExporter, +) + +from commons._tracing import TRACER_NAME, commons_span + +# The global tracer provider can only be set once per process, so one provider +# serves the whole module and each test clears the exporter instead. +_EXPORTER = InMemorySpanExporter() + + +@pytest.fixture(scope="module", autouse=True) +def _tracing_provider() -> Iterator[None]: + provider = TracerProvider() + provider.add_span_processor(SimpleSpanProcessor(_EXPORTER)) + trace.set_tracer_provider(provider) + yield + provider.shutdown() + + +@pytest.fixture(autouse=True) +def _clear_exporter() -> Iterator[None]: + _EXPORTER.clear() + yield + _EXPORTER.clear() + + +def exported() -> tuple[ReadableSpan, ...]: + return _EXPORTER.get_finished_spans() + + +def test_span_is_recorded_with_its_name() -> None: + with commons_span("commons_data_source_create"): + pass + + (span,) = exported() + assert span.name == "commons_data_source_create" + + +def test_span_carries_the_commons_tracer_name() -> None: + # The scope name identifies commons as the emitter in a shared trace + # store, alongside chatlas' own spans. + with commons_span("commons_agent_create"): + pass + + (span,) = exported() + assert span.instrumentation_scope is not None + assert span.instrumentation_scope.name == TRACER_NAME + assert TRACER_NAME == "co.posit.python-package.commons" + + +def test_attributes_given_at_the_start_are_recorded() -> None: + # Attributes are best set at creation: samplers only see those, and + # Connect snapshots them when the span opens. + with commons_span( + "commons_data_source_create", {"commons.data_source.kind": "duckdb"} + ): + pass + + (span,) = exported() + assert span.attributes is not None + assert span.attributes["commons.data_source.kind"] == "duckdb" + + +def test_attributes_set_during_the_span_are_recorded() -> None: + # Some values, a row count for instance, are only known once the work the + # span covers has started. + with commons_span("commons_data_source_list_tables") as span: + span.set_attribute("commons.data_source.n_tables", 3) + + (recorded,) = exported() + assert recorded.attributes is not None + assert recorded.attributes["commons.data_source.n_tables"] == 3 + + +def test_a_nested_span_parents_to_the_enclosing_one() -> None: + # The span is made current, not merely started: turn grouping depends on + # spans opened inside it becoming its children. + with ( + commons_span("commons_context_prewarm"), + commons_span("commons_context_store_build"), + ): + pass + + child, parent = exported() + assert child.name == "commons_context_store_build" + assert parent.name == "commons_context_prewarm" + assert child.parent is not None + assert parent.context is not None + assert child.parent.span_id == parent.context.span_id + + +def test_the_span_ends_and_records_the_error_when_the_body_raises() -> None: + with ( + pytest.raises(ValueError, match="no such table"), + commons_span("commons_data_source_list_tables"), + ): + raise ValueError("no such table") + + (span,) = exported() + assert span.end_time is not None + assert span.status.is_ok is False + assert [event.name for event in span.events] == ["exception"] + + +def run_in_fresh_interpreter(body: str) -> str: + """Run `body` in a new process, where commons has configured nothing.""" + result = subprocess.run( + [sys.executable, "-c", textwrap.dedent(body)], + capture_output=True, + text=True, + check=False, + ) + assert result.returncode == 0, result.stderr + assert result.stderr == "" + return result.stdout + + +def test_spans_are_inert_when_no_tracer_provider_is_configured() -> None: + # Setup spans are not gated behind `log=True`, so they open on every + # agent. With no provider they must cost nothing. + output = run_in_fresh_interpreter( + """ + from commons._tracing import commons_span + + with commons_span("commons_agent_create", {"a": 1}) as span: + span.set_attribute("b", 2) + print(span.is_recording()) + """ + ) + assert output.strip() == "False" diff --git a/pkg-py/uv.lock b/pkg-py/uv.lock index 11381217..c79fca2a 100644 --- a/pkg-py/uv.lock +++ b/pkg-py/uv.lock @@ -206,6 +206,7 @@ dependencies = [ { name = "chatlas" }, { name = "duckdb" }, { name = "jinja2" }, + { name = "opentelemetry-api" }, { name = "pyarrow" }, { name = "pydantic" }, { name = "pyyaml" }, @@ -237,6 +238,7 @@ requires-dist = [ { name = "duckdb", specifier = ">=1.0" }, { name = "httpx", marker = "extra == 'tracing'", specifier = ">=0.27" }, { name = "jinja2", specifier = ">=3" }, + { name = "opentelemetry-api", specifier = ">=1.0" }, { name = "opentelemetry-exporter-otlp-json-file", marker = "extra == 'tracing'" }, { name = "opentelemetry-sdk", marker = "extra == 'tracing'", specifier = ">=1.39" }, { name = "pyarrow", specifier = ">=17" },