diff --git a/pyproject.toml b/pyproject.toml index 7c1b2d1e5..7fb5a4e85 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,6 +44,10 @@ mlmodel = [ "numpy", "packaging", ] +tracing = [ + "opentelemetry-api>=1.27.0", + "opentelemetry-sdk>=1.27.0", +] [build-system] requires = ["hatchling"] diff --git a/src/viam/module/module.py b/src/viam/module/module.py index 463986c78..c12524ffb 100644 --- a/src/viam/module/module.py +++ b/src/viam/module/module.py @@ -34,6 +34,7 @@ from viam.robot.client import RobotClient from viam.rpc.dial import DialOptions, _host_port_from_url from viam.rpc.server import Server +from viam.rpc.tracing import install_parent_send_exporter # These imports are required to register built-in resources with the registry from ..components.arm import Arm # noqa: F401 @@ -171,6 +172,7 @@ async def _connect_to_parent(self): ) self.logger.debug("Starting module logging") logging.setParent(self.parent) + install_parent_send_exporter(self.parent) async def _get_resource(self, name: ResourceName) -> ResourceBase: await self._connect_to_parent() diff --git a/src/viam/rpc/dial.py b/src/viam/rpc/dial.py index 9d2073533..0e0c0b87f 100644 --- a/src/viam/rpc/dial.py +++ b/src/viam/rpc/dial.py @@ -22,6 +22,7 @@ from viam.errors import InsecureConnectionError, ViamError from viam.proto.rpc.auth import AuthenticateRequest, AuthServiceStub from viam.proto.rpc.auth import Credentials as PBCredentials +from viam.rpc.tracing import inject_trace_context from viam.version_metadata import API_VERSION, SDK_VERSION LOGGER = logging.getLogger(__name__) @@ -373,6 +374,7 @@ def _create_chan(path: str) -> Channel: async def _dial_inner(address: str, options: Optional[DialOptions] = None) -> ViamChannel: async def send_request(event: SendRequest): event.metadata["viam_client"] = f"python;v{SDK_VERSION};v{API_VERSION}" + inject_trace_context(event.metadata) opts = options if options else DialOptions() if opts.disable_webrtc: diff --git a/src/viam/rpc/server.py b/src/viam/rpc/server.py index e0621b2dd..2602906d3 100644 --- a/src/viam/rpc/server.py +++ b/src/viam/rpc/server.py @@ -17,6 +17,7 @@ from viam.resource.registry import Registry from viam.resource.rpc_service_base import ResourceRPCServiceBase from viam.robot.service import RobotService +from viam.rpc.tracing import start_server_span from .signaling import SignalingService @@ -63,23 +64,25 @@ async def _grpc_recvrequest_handler(self, event: RecvRequest): host = address[0] port = address[1] method_func = event.method_func + method_name = event.method_name async def log_resource_name(stream: Stream): recv_msg = stream.recv_message async def rcv_and_log_msg(): msg = await recv_msg() - log_msg = f"[gRPC] Received message from {host or 'xxxx'}:{port or 'xxxx'} - {event.method_name}" + log_msg = f"[gRPC] Received message from {host or 'xxxx'}:{port or 'xxxx'} - {method_name}" if msg and hasattr(msg, "name"): log_msg += f" for resource named: {msg.name}" LOGGER.debug(log_msg) return msg stream.recv_message = rcv_and_log_msg - try: - return await method_func(stream) - finally: - LOGGER.debug(f"[gRPC] Finished call from {host or 'xxxx'}:{port or 'xxxx'} - {event.method_name}") + with start_server_span(method_name, stream.metadata or {}): + try: + return await method_func(stream) + finally: + LOGGER.debug(f"[gRPC] Finished call from {host or 'xxxx'}:{port or 'xxxx'} - {method_name}") event.method_func = log_resource_name diff --git a/src/viam/rpc/tracing.py b/src/viam/rpc/tracing.py new file mode 100644 index 000000000..bef4de31c --- /dev/null +++ b/src/viam/rpc/tracing.py @@ -0,0 +1,357 @@ +# pyright: reportPossiblyUnboundVariable=false, reportRedeclaration=false, reportOptionalMemberAccess=false, reportAttributeAccessIssue=false, reportGeneralTypeIssues=false +"""Automated OpenTelemetry tracing for gRPC RPCs in the Viam Python SDK. + +Tracing is an *optional* feature. Install the ``tracing`` extra to enable +it:: + + pip install "viam-sdk[tracing]" + +Without the extra, every function in this module is a safe no-op so the +rest of the SDK works unchanged. With the extra, spans are emitted only +when the ``VIAM_MODULE_TRACING`` environment variable is set to a truthy +value, mirroring viam-cpp-sdk. + +When enabled, the W3C Trace Context propagator is installed so incoming +trace context is honored, and a custom exporter ships spans to the parent +``viam-server`` via the ``RobotService.SendTraces`` RPC. + +OTLP encoding is implemented locally against the protobuf types bundled +under ``viam.gen.opentelemetry`` so that the SDK does not depend on the +PyPI ``opentelemetry-proto`` package, which would conflict with those +bundled types in the protobuf descriptor pool. +""" + +import asyncio +import concurrent.futures +import os +import warnings +from contextlib import contextmanager +from typing import TYPE_CHECKING, Any, Iterable, List, Mapping, MutableMapping + +from viam import logging + +if TYPE_CHECKING: + from viam.robot.client import RobotClient + +LOGGER = logging.getLogger(__name__) + +TRACING_ENV_VAR = "VIAM_MODULE_TRACING" +INSTRUMENTATION_SCOPE = "viam-python-sdk" +SERVICE_NAME_VALUE = "viam-python-sdk" +_FALSY = {"", "0", "false", "no", "off"} +_EXPORT_TIMEOUT_SEC = 5 + +try: + from opentelemetry import propagate, trace + from opentelemetry.propagators.textmap import Getter, Setter + from opentelemetry.sdk.resources import SERVICE_NAME, Resource + from opentelemetry.sdk.trace import ReadableSpan, TracerProvider + from opentelemetry.sdk.trace.export import BatchSpanProcessor, SpanExporter, SpanExportResult + from opentelemetry.trace.propagation.tracecontext import TraceContextTextMapPropagator + + OTEL_AVAILABLE = True +except ImportError: + OTEL_AVAILABLE = False + + +_missing_extra_warned = False + + +def tracing_enabled() -> bool: + """Return whether tracing is enabled via the ``VIAM_MODULE_TRACING`` env var. + + Returns ``False`` when the ``tracing`` extra is not installed, warning + once if the env var is set but the extra is missing. + """ + env_set = os.environ.get(TRACING_ENV_VAR, "").strip().lower() not in _FALSY + if not env_set: + return False + if not OTEL_AVAILABLE: + global _missing_extra_warned + if not _missing_extra_warned: + warnings.warn( + f"{TRACING_ENV_VAR} is set but tracing dependencies are missing. " + "Install the [tracing] extra, e.g. `pip install 'viam-sdk[tracing]'`." + ) + _missing_extra_warned = True + return False + return True + + +_provider_installed = False + + +if OTEL_AVAILABLE: + from viam.gen.opentelemetry.proto.common.v1.common_pb2 import ( + AnyValue, + InstrumentationScope, + KeyValue, + ) + from viam.gen.opentelemetry.proto.resource.v1.resource_pb2 import Resource as PbResource + from viam.gen.opentelemetry.proto.trace.v1.trace_pb2 import ( + ResourceSpans as ViamResourceSpans, + ) + from viam.gen.opentelemetry.proto.trace.v1.trace_pb2 import ( + ScopeSpans, + Span, + Status as PbStatus, + ) + from viam.proto.robot import SendTracesRequest + + class _MetadataSetter(Setter[MutableMapping[str, Any]]): + def set(self, carrier: MutableMapping[str, Any], key: str, value: str) -> None: + carrier[key] = value + + class _MetadataGetter(Getter[Mapping[str, Any]]): + def get(self, carrier: Mapping[str, Any], key: str): + value = carrier.get(key) + if value is None: + value = carrier.get(key.lower()) + if value is None: + return None + if isinstance(value, (bytes, bytearray)): + return [value.decode("ascii", errors="replace")] + if isinstance(value, str): + return [value] + return [str(value)] + + def keys(self, carrier: Mapping[str, Any]): + return list(carrier.keys()) + + _SETTER = _MetadataSetter() + _GETTER = _MetadataGetter() + + def inject_trace_context(metadata: MutableMapping[str, Any]) -> None: + """Inject the active trace context into outgoing gRPC metadata. + + No-op until ``install_parent_send_exporter`` has installed the W3C + propagator, so import-time installation doesn't override a user's + own global propagator. + """ + if not _provider_installed: + return + propagate.inject(metadata, setter=_SETTER) + + def extract_trace_context(metadata: Mapping[str, Any]): + """Extract trace context from incoming gRPC metadata.""" + return propagate.extract(metadata, getter=_GETTER) + + def get_tracer(): + """Return the tracer for the Viam Python SDK instrumentation scope.""" + return trace.get_tracer(INSTRUMENTATION_SCOPE) + + @contextmanager + def start_server_span(method_name: str, metadata: Mapping[str, Any]): + """Context manager that opens a SERVER span for an incoming RPC. + + On exception inside the ``with`` block, records the exception and + marks the span ERROR before re-raising. + """ + if not _provider_installed: + yield None + return + parent_ctx = extract_trace_context(metadata) + tracer = get_tracer() + with tracer.start_as_current_span( + method_name, + context=parent_ctx, + kind=trace.SpanKind.SERVER, + attributes={"rpc.system": "grpc", "rpc.method": method_name}, + ) as span: + try: + yield span + except Exception as e: + span.record_exception(e) + span.set_status(trace.Status(trace.StatusCode.ERROR, str(e))) + raise + + def _any_value(v) -> AnyValue: + # bool must precede int: bool is a subclass of int. + if isinstance(v, bool): + return AnyValue(bool_value=v) + if isinstance(v, int): + return AnyValue(int_value=v) + if isinstance(v, float): + return AnyValue(double_value=v) + if isinstance(v, bytes): + return AnyValue(bytes_value=v) + return AnyValue(string_value=str(v)) + + def _kvs(attrs: Mapping[str, Any]) -> List[KeyValue]: + return [KeyValue(key=k, value=_any_value(v)) for k, v in (attrs or {}).items()] + + _OTEL_SPAN_KIND_TO_PB = { + trace.SpanKind.INTERNAL: Span.SPAN_KIND_INTERNAL, + trace.SpanKind.SERVER: Span.SPAN_KIND_SERVER, + trace.SpanKind.CLIENT: Span.SPAN_KIND_CLIENT, + trace.SpanKind.PRODUCER: Span.SPAN_KIND_PRODUCER, + trace.SpanKind.CONSUMER: Span.SPAN_KIND_CONSUMER, + } + + def _status_to_pb(status) -> PbStatus: + code_map = { + trace.StatusCode.UNSET: PbStatus.STATUS_CODE_UNSET, + trace.StatusCode.OK: PbStatus.STATUS_CODE_OK, + trace.StatusCode.ERROR: PbStatus.STATUS_CODE_ERROR, + } + return PbStatus(code=code_map.get(status.status_code, PbStatus.STATUS_CODE_UNSET), message=status.description or "") + + def _span_to_pb(span: "ReadableSpan") -> Span: + ctx = span.get_span_context() + parent = span.parent + events = [ + Span.Event( + time_unix_nano=ev.timestamp, + name=ev.name, + attributes=_kvs(dict(ev.attributes or {})), + ) + for ev in (span.events or []) + ] + links = [ + Span.Link( + trace_id=link.context.trace_id.to_bytes(16, "big"), + span_id=link.context.span_id.to_bytes(8, "big"), + attributes=_kvs(dict(link.attributes or {})), + ) + for link in (span.links or []) + ] + return Span( + trace_id=ctx.trace_id.to_bytes(16, "big"), + span_id=ctx.span_id.to_bytes(8, "big"), + parent_span_id=parent.span_id.to_bytes(8, "big") if parent else b"", + name=span.name, + kind=_OTEL_SPAN_KIND_TO_PB.get(span.kind, Span.SPAN_KIND_INTERNAL), + start_time_unix_nano=span.start_time or 0, + end_time_unix_nano=span.end_time or 0, + attributes=_kvs(dict(span.attributes or {})), + events=events, + links=links, + status=_status_to_pb(span.status), + ) + + def _spans_to_resource_spans(spans: Iterable["ReadableSpan"]) -> List[ViamResourceSpans]: + """Group ``ReadableSpan``s into ``ResourceSpans`` by resource and scope.""" + # resource_key -> (PbResource, {scope_key: (InstrumentationScope, [Span])}) + by_resource: dict = {} + for span in spans: + res = span.resource + res_attrs = tuple(sorted((res.attributes or {}).items())) if res else () + if res_attrs not in by_resource: + pb_res = PbResource(attributes=_kvs(dict(res.attributes) if res else {})) + by_resource[res_attrs] = (pb_res, {}) + _, scopes = by_resource[res_attrs] + + inst = span.instrumentation_scope + scope_name = inst.name if inst else "" + scope_version = inst.version if inst and inst.version else "" + scope_key = (scope_name, scope_version) + if scope_key not in scopes: + scopes[scope_key] = (InstrumentationScope(name=scope_name, version=scope_version), []) + scopes[scope_key][1].append(_span_to_pb(span)) + + out: List[ViamResourceSpans] = [] + for pb_res, scopes in by_resource.values(): + scope_spans = [ScopeSpans(scope=scope, spans=spans_pb) for scope, spans_pb in scopes.values()] + out.append(ViamResourceSpans(resource=pb_res, scope_spans=scope_spans)) + return out + + class ParentSendTracesExporter(SpanExporter): + """Exporter that ships spans to the parent robot via ``SendTraces``. + + Mirrors viam-cpp-sdk's ``ParentSendTracesExporter``. + """ + + def __init__(self, robot_client: "RobotClient", loop: asyncio.AbstractEventLoop) -> None: + """Capture the parent module's event loop for cross-thread dispatch. + + ``BatchSpanProcessor`` calls ``export`` from a worker thread, so + the RPC must be scheduled back onto the loop that owns + ``robot_client``'s gRPC channel. + """ + self._robot_client = robot_client + self._loop = loop + self._shutdown = False + + def export(self, spans) -> "SpanExportResult": + if self._shutdown: + return SpanExportResult.FAILURE + if not spans: + return SpanExportResult.SUCCESS + try: + req = SendTracesRequest(resource_spans=_spans_to_resource_spans(spans)) + coro = self._robot_client._client.SendTraces(req) + fut = asyncio.run_coroutine_threadsafe(coro, self._loop) + try: + fut.result(timeout=_EXPORT_TIMEOUT_SEC) + except concurrent.futures.TimeoutError: + # Don't leak the in-flight RPC on the parent loop. + fut.cancel() + raise + return SpanExportResult.SUCCESS + except Exception as e: + LOGGER.debug(f"Failed to export traces to parent: {e}") + return SpanExportResult.FAILURE + + def shutdown(self) -> None: + self._shutdown = True + + def force_flush(self, timeout_millis: int = 30000) -> bool: + return True + + def install_parent_send_exporter(robot_client: "RobotClient") -> bool: + """Install a ``TracerProvider`` that exports spans to ``robot_client``. + + Must be called from the event loop that owns ``robot_client``'s gRPC + channel; that loop is captured for cross-thread dispatch from the + ``BatchSpanProcessor`` worker. + + No-op if tracing is disabled or already installed. Returns ``True`` + only when the provider was installed in this call. + """ + global _provider_installed + if _provider_installed: + return False + if not tracing_enabled(): + return False + loop = asyncio.get_running_loop() + # Install the W3C propagator here rather than at import so we don't + # clobber a user's own propagator unless they opted into tracing. + propagate.set_global_textmap(TraceContextTextMapPropagator()) + resource = Resource.create({SERVICE_NAME: SERVICE_NAME_VALUE}) + provider = TracerProvider(resource=resource) + provider.add_span_processor(BatchSpanProcessor(ParentSendTracesExporter(robot_client, loop))) + trace.set_tracer_provider(provider) + _provider_installed = True + LOGGER.debug("Viam tracing enabled; spans will be forwarded to parent robot") + return True + +else: + # No-op fallbacks when opentelemetry is not installed. + + def inject_trace_context(metadata: MutableMapping[str, Any]) -> None: # type: ignore[misc] + return + + def extract_trace_context(metadata: Mapping[str, Any]): # type: ignore[misc] + return None + + def get_tracer(): # type: ignore[misc] + return None + + @contextmanager + def start_server_span(method_name: str, metadata: Mapping[str, Any]): # type: ignore[misc] + yield None + + def install_parent_send_exporter(robot_client: "RobotClient") -> bool: # type: ignore[misc] + return False + + +def _reset_for_tests() -> None: + """Test-only helper: clear the installed-provider sentinel.""" + global _provider_installed + _provider_installed = False + + +def _force_installed_for_tests() -> None: + """Test-only helper: mark the provider as installed without wiring exporter.""" + global _provider_installed + _provider_installed = True diff --git a/tests/test_tracing.py b/tests/test_tracing.py new file mode 100644 index 000000000..cd873ef1b --- /dev/null +++ b/tests/test_tracing.py @@ -0,0 +1,296 @@ +import asyncio +import os +import threading +from unittest import mock + +import pytest + +from viam.rpc import tracing + + +pytestmark = pytest.mark.skipif(not tracing.OTEL_AVAILABLE, reason="opentelemetry extra not installed") + + +if tracing.OTEL_AVAILABLE: + from opentelemetry import trace + from opentelemetry.sdk.trace import TracerProvider + from opentelemetry.sdk.trace.export import SimpleSpanProcessor, SpanExportResult + from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter + + +@pytest.fixture(scope="module", autouse=True) +def _global_tracer_provider(): + """Install one TracerProvider for the whole test module. + + OpenTelemetry refuses to override an already-installed global + TracerProvider, so we set it once and share it across tests. + """ + provider = TracerProvider() + trace.set_tracer_provider(provider) + yield provider + + +@pytest.fixture() +def memory_exporter(_global_tracer_provider): + exporter = InMemorySpanExporter() + processor = SimpleSpanProcessor(exporter) + _global_tracer_provider.add_span_processor(processor) + yield exporter + exporter.clear() + + +@pytest.fixture(autouse=True) +def _reset_tracing_state(): + tracing._reset_for_tests() + tracing._missing_extra_warned = False + yield + tracing._reset_for_tests() + tracing._missing_extra_warned = False + + +class TestMissingExtraWarning: + def test_warns_once_when_env_set_but_otel_absent(self): + with mock.patch.object(tracing, "OTEL_AVAILABLE", False): + with mock.patch.dict(os.environ, {tracing.TRACING_ENV_VAR: "1"}): + with pytest.warns(UserWarning, match=r"tracing dependencies are missing"): + assert tracing.tracing_enabled() is False + # Second call must not warn again. + import warnings as _w + + with _w.catch_warnings(): + _w.simplefilter("error") + assert tracing.tracing_enabled() is False + + def test_no_warning_when_env_unset(self): + with mock.patch.object(tracing, "OTEL_AVAILABLE", False): + with mock.patch.dict(os.environ, {}, clear=True): + import warnings as _w + + with _w.catch_warnings(): + _w.simplefilter("error") + assert tracing.tracing_enabled() is False + + +class TestTracingEnabled: + def test_disabled_by_default(self): + with mock.patch.dict(os.environ, {}, clear=True): + assert tracing.tracing_enabled() is False + + @pytest.mark.parametrize("val", ["", "0", "false", "FALSE", "no", "off"]) + def test_falsy_disables(self, val): + with mock.patch.dict(os.environ, {tracing.TRACING_ENV_VAR: val}): + assert tracing.tracing_enabled() is False + + @pytest.mark.parametrize("val", ["1", "true", "yes", "on", "anything"]) + def test_truthy_enables(self, val): + with mock.patch.dict(os.environ, {tracing.TRACING_ENV_VAR: val}): + assert tracing.tracing_enabled() is True + + +class TestContextPropagation: + def test_inject_writes_traceparent_when_active_span(self, _global_tracer_provider): + tracing._force_installed_for_tests() + tracer = trace.get_tracer("test") + with tracer.start_as_current_span("outer"): + md: dict = {} + tracing.inject_trace_context(md) + assert "traceparent" in md + traceparent = md["traceparent"] + + extracted = tracing.extract_trace_context({"traceparent": traceparent}) + span_ctx = trace.get_current_span(extracted).get_span_context() + assert span_ctx.is_valid + + def test_extract_handles_bytes_values(self, _global_tracer_provider): + tracing._force_installed_for_tests() + tracer = trace.get_tracer("test") + with tracer.start_as_current_span("outer"): + md: dict = {} + tracing.inject_trace_context(md) + tp = md["traceparent"] + + extracted = tracing.extract_trace_context({"traceparent": tp.encode("ascii")}) + span_ctx = trace.get_current_span(extracted).get_span_context() + assert span_ctx.is_valid + + def test_inject_noop_when_not_installed(self, _global_tracer_provider): + tracer = trace.get_tracer("test") + with tracer.start_as_current_span("outer"): + md: dict = {} + tracing.inject_trace_context(md) + assert md == {} + + +class TestStartServerSpan: + def test_no_provider_yields_none(self): + # _reset_tracing_state cleared the sentinel; should be no-op. + with tracing.start_server_span("Foo", {}) as span: + assert span is None + + def test_with_provider_creates_span_and_extracts_parent(self, memory_exporter): + tracing._force_installed_for_tests() + + tracer = trace.get_tracer("test") + with tracer.start_as_current_span("parent") as parent: + md: dict = {} + tracing.inject_trace_context(md) + parent_trace_id = parent.get_span_context().trace_id + + with tracing.start_server_span("DoThing", md) as span: + assert span is not None + assert span.is_recording() + assert span.get_span_context().trace_id == parent_trace_id + + spans = memory_exporter.get_finished_spans() + server_spans = [s for s in spans if s.name == "DoThing"] + assert len(server_spans) == 1 + s = server_spans[0] + assert s.kind == trace.SpanKind.SERVER + assert s.attributes["rpc.system"] == "grpc" + assert s.attributes["rpc.method"] == "DoThing" + + def test_records_exception(self, memory_exporter): + tracing._force_installed_for_tests() + + with pytest.raises(ValueError): + with tracing.start_server_span("Bad", {}): + raise ValueError("boom") + + spans = memory_exporter.get_finished_spans() + bad = [s for s in spans if s.name == "Bad"] + assert len(bad) == 1 + assert bad[0].status.status_code == trace.StatusCode.ERROR + assert any(ev.name == "exception" for ev in bad[0].events) + + +class TestInstallExporter: + def test_noop_when_disabled(self): + with mock.patch.dict(os.environ, {}, clear=True): + assert tracing.install_parent_send_exporter(mock.Mock()) is False + + def test_noop_when_already_installed(self): + with mock.patch.dict(os.environ, {tracing.TRACING_ENV_VAR: "1"}): + tracing._force_installed_for_tests() + assert tracing.install_parent_send_exporter(mock.Mock()) is False + + +def _start_background_loop(): + """Spin up an asyncio loop on a worker thread; return (loop, stop_fn).""" + loop_ready = threading.Event() + loop_holder: dict = {} + + def run_loop(): + loop = asyncio.new_event_loop() + loop_holder["loop"] = loop + asyncio.set_event_loop(loop) + loop_ready.set() + try: + loop.run_forever() + finally: + loop.close() + + thread = threading.Thread(target=run_loop, daemon=True) + thread.start() + loop_ready.wait() + loop = loop_holder["loop"] + + def stop(): + loop.call_soon_threadsafe(loop.stop) + thread.join(timeout=2) + + return loop, stop + + +class TestParentSendExporter: + def test_export_dispatches_to_captured_loop(self, _global_tracer_provider): + captured_loops: list = [] + + async def fake_send_traces(req): + captured_loops.append(asyncio.get_running_loop()) + return mock.Mock() + + loop, stop = _start_background_loop() + try: + robot_client = mock.Mock() + robot_client._client.SendTraces = fake_send_traces + exporter = tracing.ParentSendTracesExporter(robot_client, loop) + + tracer = trace.get_tracer("test-export") + sink = InMemorySpanExporter() + proc = SimpleSpanProcessor(sink) + _global_tracer_provider.add_span_processor(proc) + with tracer.start_as_current_span("export-me"): + pass + readable = [s for s in sink.get_finished_spans() if s.name == "export-me"] + + assert exporter.export(readable) == SpanExportResult.SUCCESS + assert captured_loops == [loop] + finally: + stop() + + def test_export_cancels_on_timeout(self, _global_tracer_provider): + cancelled = threading.Event() + + async def slow_send_traces(req): + try: + await asyncio.sleep(10) + except asyncio.CancelledError: + cancelled.set() + raise + + loop, stop = _start_background_loop() + try: + robot_client = mock.Mock() + robot_client._client.SendTraces = slow_send_traces + exporter = tracing.ParentSendTracesExporter(robot_client, loop) + + tracer = trace.get_tracer("test-timeout") + sink = InMemorySpanExporter() + _global_tracer_provider.add_span_processor(SimpleSpanProcessor(sink)) + with tracer.start_as_current_span("timeout-me"): + pass + readable = [s for s in sink.get_finished_spans() if s.name == "timeout-me"] + + with mock.patch.object(tracing, "_EXPORT_TIMEOUT_SEC", 0.05): + assert exporter.export(readable) == SpanExportResult.FAILURE + assert cancelled.wait(timeout=2), "in-flight RPC should have been cancelled" + finally: + stop() + + def test_export_noop_on_empty(self): + exporter = tracing.ParentSendTracesExporter(mock.Mock(), mock.Mock()) + assert exporter.export([]) == SpanExportResult.SUCCESS + + def test_export_failure_after_shutdown(self): + exporter = tracing.ParentSendTracesExporter(mock.Mock(), mock.Mock()) + exporter.shutdown() + assert exporter.export([mock.Mock()]) == SpanExportResult.FAILURE + + +class TestSpansToResourceSpans: + def test_encodes_span_attributes_and_status(self, _global_tracer_provider): + tracer = trace.get_tracer("scope-x") + with tracer.start_as_current_span("op", attributes={"a": 1, "b": "two"}) as span: + span.set_status(trace.Status(trace.StatusCode.ERROR, "nope")) + + from opentelemetry.sdk.trace import ReadableSpan + + # Build a ReadableSpan from the just-ended span by exporting via a private collector. + exporter = InMemorySpanExporter() + proc = SimpleSpanProcessor(exporter) + _global_tracer_provider.add_span_processor(proc) + with tracer.start_as_current_span("op2", attributes={"k": True}) as s2: + s2.set_status(trace.Status(trace.StatusCode.ERROR, "x")) + readable: ReadableSpan = exporter.get_finished_spans()[0] + + out = tracing._spans_to_resource_spans([readable]) + assert len(out) == 1 + rs = out[0] + assert len(rs.scope_spans) == 1 + ss = rs.scope_spans[0] + assert ss.scope.name == "scope-x" + assert len(ss.spans) == 1 + pb = ss.spans[0] + assert pb.name == "op2" + assert any(kv.key == "k" and kv.value.bool_value is True for kv in pb.attributes) + assert pb.status.code == pb.status.STATUS_CODE_ERROR diff --git a/uv.lock b/uv.lock index 18ea744e9..bb765aada 100644 --- a/uv.lock +++ b/uv.lock @@ -535,6 +535,7 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/38/3f/9859f655d11901e7b2996c6e3d33e0caa9a1d4572c3bc61ed0faa64b2f4c/greenlet-3.3.2-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:9bc885b89709d901859cf95179ec9f6bb67a3d2bb1f0e88456461bd4b7f8fd0d", size = 277747, upload-time = "2026-02-20T20:16:21.325Z" }, { url = "https://files.pythonhosted.org/packages/fb/07/cb284a8b5c6498dbd7cba35d31380bb123d7dceaa7907f606c8ff5993cbf/greenlet-3.3.2-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b568183cf65b94919be4438dc28416b234b678c608cafac8874dfeeb2a9bbe13", size = 579202, upload-time = "2026-02-20T20:47:28.955Z" }, { url = "https://files.pythonhosted.org/packages/ed/45/67922992b3a152f726163b19f890a85129a992f39607a2a53155de3448b8/greenlet-3.3.2-cp310-cp310-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:527fec58dc9f90efd594b9b700662ed3fb2493c2122067ac9c740d98080a620e", size = 590620, upload-time = "2026-02-20T20:55:55.581Z" }, + { url = "https://files.pythonhosted.org/packages/03/5f/6e2a7d80c353587751ef3d44bb947f0565ec008a2e0927821c007e96d3a7/greenlet-3.3.2-cp310-cp310-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:508c7f01f1791fbc8e011bd508f6794cb95397fdb198a46cb6635eb5b78d85a7", size = 602132, upload-time = "2026-02-20T21:02:43.261Z" }, { url = "https://files.pythonhosted.org/packages/ad/55/9f1ebb5a825215fadcc0f7d5073f6e79e3007e3282b14b22d6aba7ca6cb8/greenlet-3.3.2-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ad0c8917dd42a819fe77e6bdfcb84e3379c0de956469301d9fd36427a1ca501f", size = 591729, upload-time = "2026-02-20T20:20:58.395Z" }, { url = "https://files.pythonhosted.org/packages/24/b4/21f5455773d37f94b866eb3cf5caed88d6cea6dd2c6e1f9c34f463cba3ec/greenlet-3.3.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:97245cc10e5515dbc8c3104b2928f7f02b6813002770cfaffaf9a6e0fc2b94ef", size = 1551946, upload-time = "2026-02-20T20:49:31.102Z" }, { url = "https://files.pythonhosted.org/packages/00/68/91f061a926abead128fe1a87f0b453ccf07368666bd59ffa46016627a930/greenlet-3.3.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8c1fdd7d1b309ff0da81d60a9688a8bd044ac4e18b250320a96fc68d31c209ca", size = 1618494, upload-time = "2026-02-20T20:21:06.541Z" }, @@ -542,6 +543,7 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f3/47/16400cb42d18d7a6bb46f0626852c1718612e35dcb0dffa16bbaffdf5dd2/greenlet-3.3.2-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:c56692189a7d1c7606cb794be0a8381470d95c57ce5be03fb3d0ef57c7853b86", size = 278890, upload-time = "2026-02-20T20:19:39.263Z" }, { url = "https://files.pythonhosted.org/packages/a3/90/42762b77a5b6aa96cd8c0e80612663d39211e8ae8a6cd47c7f1249a66262/greenlet-3.3.2-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ebd458fa8285960f382841da585e02201b53a5ec2bac6b156fc623b5ce4499f", size = 581120, upload-time = "2026-02-20T20:47:30.161Z" }, { url = "https://files.pythonhosted.org/packages/bf/6f/f3d64f4fa0a9c7b5c5b3c810ff1df614540d5aa7d519261b53fba55d4df9/greenlet-3.3.2-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a443358b33c4ec7b05b79a7c8b466f5d275025e750298be7340f8fc63dff2a55", size = 594363, upload-time = "2026-02-20T20:55:56.965Z" }, + { url = "https://files.pythonhosted.org/packages/9c/8b/1430a04657735a3f23116c2e0d5eb10220928846e4537a938a41b350bed6/greenlet-3.3.2-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4375a58e49522698d3e70cc0b801c19433021b5c37686f7ce9c65b0d5c8677d2", size = 605046, upload-time = "2026-02-20T21:02:45.234Z" }, { url = "https://files.pythonhosted.org/packages/72/83/3e06a52aca8128bdd4dcd67e932b809e76a96ab8c232a8b025b2850264c5/greenlet-3.3.2-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e2cd90d413acbf5e77ae41e5d3c9b3ac1d011a756d7284d7f3f2b806bbd6358", size = 594156, upload-time = "2026-02-20T20:20:59.955Z" }, { url = "https://files.pythonhosted.org/packages/70/79/0de5e62b873e08fe3cef7dbe84e5c4bc0e8ed0c7ff131bccb8405cd107c8/greenlet-3.3.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:442b6057453c8cb29b4fb36a2ac689382fc71112273726e2423f7f17dc73bf99", size = 1554649, upload-time = "2026-02-20T20:49:32.293Z" }, { url = "https://files.pythonhosted.org/packages/5a/00/32d30dee8389dc36d42170a9c66217757289e2afb0de59a3565260f38373/greenlet-3.3.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:45abe8eb6339518180d5a7fa47fa01945414d7cca5ecb745346fc6a87d2750be", size = 1619472, upload-time = "2026-02-20T20:21:07.966Z" }, @@ -550,6 +552,7 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ea/ab/1608e5a7578e62113506740b88066bf09888322a311cff602105e619bd87/greenlet-3.3.2-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:ac8d61d4343b799d1e526db579833d72f23759c71e07181c2d2944e429eb09cd", size = 280358, upload-time = "2026-02-20T20:17:43.971Z" }, { url = "https://files.pythonhosted.org/packages/a5/23/0eae412a4ade4e6623ff7626e38998cb9b11e9ff1ebacaa021e4e108ec15/greenlet-3.3.2-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ceec72030dae6ac0c8ed7591b96b70410a8be370b6a477b1dbc072856ad02bd", size = 601217, upload-time = "2026-02-20T20:47:31.462Z" }, { url = "https://files.pythonhosted.org/packages/f8/16/5b1678a9c07098ecb9ab2dd159fafaf12e963293e61ee8d10ecb55273e5e/greenlet-3.3.2-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a2a5be83a45ce6188c045bcc44b0ee037d6a518978de9a5d97438548b953a1ac", size = 611792, upload-time = "2026-02-20T20:55:58.423Z" }, + { url = "https://files.pythonhosted.org/packages/5c/c5/cc09412a29e43406eba18d61c70baa936e299bc27e074e2be3806ed29098/greenlet-3.3.2-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ae9e21c84035c490506c17002f5c8ab25f980205c3e61ddb3a2a2a2e6c411fcb", size = 626250, upload-time = "2026-02-20T21:02:46.596Z" }, { url = "https://files.pythonhosted.org/packages/50/1f/5155f55bd71cabd03765a4aac9ac446be129895271f73872c36ebd4b04b6/greenlet-3.3.2-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43e99d1749147ac21dde49b99c9abffcbc1e2d55c67501465ef0930d6e78e070", size = 613875, upload-time = "2026-02-20T20:21:01.102Z" }, { url = "https://files.pythonhosted.org/packages/fc/dd/845f249c3fcd69e32df80cdab059b4be8b766ef5830a3d0aa9d6cad55beb/greenlet-3.3.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4c956a19350e2c37f2c48b336a3afb4bff120b36076d9d7fb68cb44e05d95b79", size = 1571467, upload-time = "2026-02-20T20:49:33.495Z" }, { url = "https://files.pythonhosted.org/packages/2a/50/2649fe21fcc2b56659a452868e695634722a6655ba245d9f77f5656010bf/greenlet-3.3.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6c6f8ba97d17a1e7d664151284cb3315fc5f8353e75221ed4324f84eb162b395", size = 1640001, upload-time = "2026-02-20T20:21:09.154Z" }, @@ -558,6 +561,7 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ac/48/f8b875fa7dea7dd9b33245e37f065af59df6a25af2f9561efa8d822fde51/greenlet-3.3.2-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:aa6ac98bdfd716a749b84d4034486863fd81c3abde9aa3cf8eff9127981a4ae4", size = 279120, upload-time = "2026-02-20T20:19:01.9Z" }, { url = "https://files.pythonhosted.org/packages/49/8d/9771d03e7a8b1ee456511961e1b97a6d77ae1dea4a34a5b98eee706689d3/greenlet-3.3.2-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ab0c7e7901a00bc0a7284907273dc165b32e0d109a6713babd04471327ff7986", size = 603238, upload-time = "2026-02-20T20:47:32.873Z" }, { url = "https://files.pythonhosted.org/packages/59/0e/4223c2bbb63cd5c97f28ffb2a8aee71bdfb30b323c35d409450f51b91e3e/greenlet-3.3.2-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d248d8c23c67d2291ffd47af766e2a3aa9fa1c6703155c099feb11f526c63a92", size = 614219, upload-time = "2026-02-20T20:55:59.817Z" }, + { url = "https://files.pythonhosted.org/packages/94/2b/4d012a69759ac9d77210b8bfb128bc621125f5b20fc398bce3940d036b1c/greenlet-3.3.2-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ccd21bb86944ca9be6d967cf7691e658e43417782bce90b5d2faeda0ff78a7dd", size = 628268, upload-time = "2026-02-20T21:02:48.024Z" }, { url = "https://files.pythonhosted.org/packages/7a/34/259b28ea7a2a0c904b11cd36c79b8cef8019b26ee5dbe24e73b469dea347/greenlet-3.3.2-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b6997d360a4e6a4e936c0f9625b1c20416b8a0ea18a8e19cabbefc712e7397ab", size = 616774, upload-time = "2026-02-20T20:21:02.454Z" }, { url = "https://files.pythonhosted.org/packages/0a/03/996c2d1689d486a6e199cb0f1cf9e4aa940c500e01bdf201299d7d61fa69/greenlet-3.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:64970c33a50551c7c50491671265d8954046cb6e8e2999aacdd60e439b70418a", size = 1571277, upload-time = "2026-02-20T20:49:34.795Z" }, { url = "https://files.pythonhosted.org/packages/d9/c4/2570fc07f34a39f2caf0bf9f24b0a1a0a47bc2e8e465b2c2424821389dfc/greenlet-3.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1a9172f5bf6bd88e6ba5a84e0a68afeac9dc7b6b412b245dd64f52d83c81e55b", size = 1640455, upload-time = "2026-02-20T20:21:10.261Z" }, @@ -566,6 +570,7 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3f/ae/8bffcbd373b57a5992cd077cbe8858fff39110480a9d50697091faea6f39/greenlet-3.3.2-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:8d1658d7291f9859beed69a776c10822a0a799bc4bfe1bd4272bb60e62507dab", size = 279650, upload-time = "2026-02-20T20:18:00.783Z" }, { url = "https://files.pythonhosted.org/packages/d1/c0/45f93f348fa49abf32ac8439938726c480bd96b2a3c6f4d949ec0124b69f/greenlet-3.3.2-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18cb1b7337bca281915b3c5d5ae19f4e76d35e1df80f4ad3c1a7be91fadf1082", size = 650295, upload-time = "2026-02-20T20:47:34.036Z" }, { url = "https://files.pythonhosted.org/packages/b3/de/dd7589b3f2b8372069ab3e4763ea5329940fc7ad9dcd3e272a37516d7c9b/greenlet-3.3.2-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c2e47408e8ce1c6f1ceea0dffcdf6ebb85cc09e55c7af407c99f1112016e45e9", size = 662163, upload-time = "2026-02-20T20:56:01.295Z" }, + { url = "https://files.pythonhosted.org/packages/cd/ac/85804f74f1ccea31ba518dcc8ee6f14c79f73fe36fa1beba38930806df09/greenlet-3.3.2-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e3cb43ce200f59483eb82949bf1835a99cf43d7571e900d7c8d5c62cdf25d2f9", size = 675371, upload-time = "2026-02-20T21:02:49.664Z" }, { url = "https://files.pythonhosted.org/packages/d2/d8/09bfa816572a4d83bccd6750df1926f79158b1c36c5f73786e26dbe4ee38/greenlet-3.3.2-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:63d10328839d1973e5ba35e98cccbca71b232b14051fd957b6f8b6e8e80d0506", size = 664160, upload-time = "2026-02-20T20:21:04.015Z" }, { url = "https://files.pythonhosted.org/packages/48/cf/56832f0c8255d27f6c35d41b5ec91168d74ec721d85f01a12131eec6b93c/greenlet-3.3.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8e4ab3cfb02993c8cc248ea73d7dae6cec0253e9afa311c9b37e603ca9fad2ce", size = 1619181, upload-time = "2026-02-20T20:49:36.052Z" }, { url = "https://files.pythonhosted.org/packages/0a/23/b90b60a4aabb4cec0796e55f25ffbfb579a907c3898cd2905c8918acaa16/greenlet-3.3.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:94ad81f0fd3c0c0681a018a976e5c2bd2ca2d9d94895f23e7bb1af4e8af4e2d5", size = 1687713, upload-time = "2026-02-20T20:21:11.684Z" }, @@ -574,6 +579,7 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/98/6d/8f2ef704e614bcf58ed43cfb8d87afa1c285e98194ab2cfad351bf04f81e/greenlet-3.3.2-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:e26e72bec7ab387ac80caa7496e0f908ff954f31065b0ffc1f8ecb1338b11b54", size = 286617, upload-time = "2026-02-20T20:19:29.856Z" }, { url = "https://files.pythonhosted.org/packages/5e/0d/93894161d307c6ea237a43988f27eba0947b360b99ac5239ad3fe09f0b47/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b466dff7a4ffda6ca975979bab80bdadde979e29fc947ac3be4451428d8b0e4", size = 655189, upload-time = "2026-02-20T20:47:35.742Z" }, { url = "https://files.pythonhosted.org/packages/f5/2c/d2d506ebd8abcb57386ec4f7ba20f4030cbe56eae541bc6fd6ef399c0b41/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b8bddc5b73c9720bea487b3bffdb1840fe4e3656fba3bd40aa1489e9f37877ff", size = 658225, upload-time = "2026-02-20T20:56:02.527Z" }, + { url = "https://files.pythonhosted.org/packages/d1/67/8197b7e7e602150938049d8e7f30de1660cfb87e4c8ee349b42b67bdb2e1/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:59b3e2c40f6706b05a9cd299c836c6aa2378cabe25d021acd80f13abf81181cf", size = 666581, upload-time = "2026-02-20T21:02:51.526Z" }, { url = "https://files.pythonhosted.org/packages/8e/30/3a09155fbf728673a1dea713572d2d31159f824a37c22da82127056c44e4/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b26b0f4428b871a751968285a1ac9648944cea09807177ac639b030bddebcea4", size = 657907, upload-time = "2026-02-20T20:21:05.259Z" }, { url = "https://files.pythonhosted.org/packages/f3/fd/d05a4b7acd0154ed758797f0a43b4c0962a843bedfe980115e842c5b2d08/greenlet-3.3.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1fb39a11ee2e4d94be9a76671482be9398560955c9e568550de0224e41104727", size = 1618857, upload-time = "2026-02-20T20:49:37.309Z" }, { url = "https://files.pythonhosted.org/packages/6f/e1/50ee92a5db521de8f35075b5eff060dd43d39ebd46c2181a2042f7070385/greenlet-3.3.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:20154044d9085151bc309e7689d6f7ba10027f8f5a8c0676ad398b951913d89e", size = 1680010, upload-time = "2026-02-20T20:21:13.427Z" }, @@ -1455,6 +1461,45 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/57/a7/b35835e278c18b85206834b3aa3abe68e77a98769c59233d1f6300284781/numpy-2.4.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:4b42639cdde6d24e732ff823a3fa5b701d8acad89c4142bc1d0bd6dc85200ba5", size = 12504685, upload-time = "2026-03-09T07:58:50.525Z" }, ] +[[package]] +name = "opentelemetry-api" +version = "1.42.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b4/1c/125e1c936c0873796771b7f04f6c93b9f1bf5d424cea90fda94a99f61da8/opentelemetry_api-1.42.1.tar.gz", hash = "sha256:56c63bea9f77b62856be8c47600474acad853b2924b99b1687c4cb6297166716", size = 72296, upload-time = "2026-05-21T16:32:49.335Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/ca/9520cc1f3dfbbd03ac5903bbf55833e257bc64b1cf30fa8b0d6df374d821/opentelemetry_api-1.42.1-py3-none-any.whl", hash = "sha256:51a69edacadbc03a8950ace1c4c21099cacc538820ac2c9e36277e78cebba714", size = 61311, upload-time = "2026-05-21T16:32:28.822Z" }, +] + +[[package]] +name = "opentelemetry-sdk" +version = "1.42.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/40/f7/b390bd9bfd703bf98a68fea1f27786c6872331fd617164a54b8a59bdc008/opentelemetry_sdk-1.42.1.tar.gz", hash = "sha256:8c834e8f8c9ba4171d4ec843d0cb8a67e4c7394d3f9e9297e582cbd9456ddbf7", size = 239262, upload-time = "2026-05-21T16:33:04.641Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8f/6b/4287766cfbde577ae2272e8884abac325aeaac0d64f41c61d5b8cc595105/opentelemetry_sdk-1.42.1-py3-none-any.whl", hash = "sha256:083cd4bbfaa5aa7b5a9e552430d9951219967cfb27aa61feb13a77aba1fc839d", size = 170907, upload-time = "2026-05-21T16:32:45.894Z" }, +] + +[[package]] +name = "opentelemetry-semantic-conventions" +version = "0.63b1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/93/99/4d7dd6df64795951413ce6e815f8cf1eb191daf7196ae86574589643d5f3/opentelemetry_semantic_conventions-0.63b1.tar.gz", hash = "sha256:3daf963611334b365e98a57438183eb012d3bfb40b2d931a9af613476b8701a9", size = 148340, upload-time = "2026-05-21T16:33:05.455Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/7a/7fe66f5f3682b1dd47d88cc4e11f1c6c0966b737de2d16671146e23c39a5/opentelemetry_semantic_conventions-0.63b1-py3-none-any.whl", hash = "sha256:dfe5ef4dee82586b746f522b818ceb298d00b3d59f660042bd79404bff8d0682", size = 203713, upload-time = "2026-05-21T16:32:47.016Z" }, +] + [[package]] name = "packaging" version = "26.0" @@ -2562,6 +2607,10 @@ mlmodel = [ { name = "numpy", version = "2.4.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "packaging" }, ] +tracing = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-sdk" }, +] [package.dev-dependencies] dev = [ @@ -2585,12 +2634,14 @@ requires-dist = [ { name = "googleapis-common-protos", specifier = ">=1.73.0" }, { name = "grpclib", specifier = ">=0.4.9" }, { name = "numpy", marker = "extra == 'mlmodel'" }, + { name = "opentelemetry-api", marker = "extra == 'tracing'", specifier = ">=1.27.0" }, + { name = "opentelemetry-sdk", marker = "extra == 'tracing'", specifier = ">=1.27.0" }, { name = "packaging", marker = "extra == 'mlmodel'" }, { name = "protobuf", specifier = "==6.33.5" }, { name = "pymongo", specifier = ">=4.10.1" }, { name = "typing-extensions", specifier = ">=4.15.0" }, ] -provides-extras = ["mlmodel"] +provides-extras = ["mlmodel", "tracing"] [package.metadata.requires-dev] dev = [