From 792196becb895744fe7bb9a5e5b96fd19b18702d Mon Sep 17 00:00:00 2001 From: Pavan Sudheendra Date: Wed, 9 Sep 2026 12:58:46 +0100 Subject: [PATCH 1/3] feat: emit runtime events from third-party OTel spans Signed-off-by: Pavan Sudheendra --- examples/realtime_demo/third_party_otel.py | 127 +++++ ioa_observe/sdk/tracing/__init__.py | 14 + .../sdk/tracing/runtime_event_processor.py | 486 ++++++++++++++++++ ioa_observe/sdk/tracing/runtime_events.py | 5 + tests/test_runtime_event_processor.py | 267 ++++++++++ tests/test_runtime_event_span_processor.py | 79 +++ 6 files changed, 978 insertions(+) create mode 100644 examples/realtime_demo/third_party_otel.py create mode 100644 ioa_observe/sdk/tracing/runtime_event_processor.py create mode 100644 tests/test_runtime_event_processor.py create mode 100644 tests/test_runtime_event_span_processor.py diff --git a/examples/realtime_demo/third_party_otel.py b/examples/realtime_demo/third_party_otel.py new file mode 100644 index 0000000..f233be1 --- /dev/null +++ b/examples/realtime_demo/third_party_otel.py @@ -0,0 +1,127 @@ +# Copyright AGNTCY Contributors (https://github.com/agntcy) +# SPDX-License-Identifier: Apache-2.0 + +"""Instrument GenAI work with an application-owned OpenTelemetry SDK.""" + +from __future__ import annotations + +import json +import os + +from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter +from opentelemetry.sdk.resources import Resource +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.sdk.trace.export import BatchSpanProcessor +from opentelemetry.trace import SpanKind, Tracer + +from ioa_observe.sdk.tracing import ( + RuntimeEventSpanProcessor, +) + + +def run_weather_agent(tracer: Tracer) -> None: + conversation_id = "conversation-123" + with tracer.start_as_current_span( + "invoke_agent Weather Agent", + kind=SpanKind.INTERNAL, + attributes={ + "gen_ai.operation.name": "invoke_agent", + "gen_ai.conversation.id": conversation_id, + "gen_ai.agent.name": "Weather Agent", + }, + ): + with tracer.start_as_current_span( + "chat gpt-5", + kind=SpanKind.CLIENT, + attributes={ + "gen_ai.operation.name": "chat", + "gen_ai.provider.name": "openai", + "gen_ai.conversation.id": conversation_id, + "gen_ai.request.model": "gpt-5", + "gen_ai.input.messages": json.dumps( + [ + { + "role": "user", + "parts": [{"type": "text", "content": "Weather?"}], + } + ] + ), + }, + ) as model_span: + model_span.set_attribute("gen_ai.response.model", "gpt-5") + model_span.set_attribute("gen_ai.response.id", "response-123") + model_span.set_attribute( + "gen_ai.output.messages", + json.dumps( + [ + { + "role": "assistant", + "parts": [ + { + "type": "tool_call", + "id": "call-123", + "name": "get_weather", + } + ], + } + ] + ), + ) + + with tracer.start_as_current_span( + "execute_tool get_weather", + kind=SpanKind.INTERNAL, + attributes={ + "gen_ai.operation.name": "execute_tool", + "gen_ai.conversation.id": conversation_id, + "gen_ai.agent.name": "Weather Agent", + "gen_ai.tool.name": "get_weather", + "gen_ai.tool.call.id": "call-123", + "gen_ai.tool.call.arguments": json.dumps({"city": "London"}), + }, + ) as tool_span: + tool_span.set_attribute( + "gen_ai.tool.call.result", + json.dumps({"temperature_celsius": 18, "conditions": "cloudy"}), + ) + + +def main() -> None: + collector_endpoint = os.getenv( + "OTEL_EXPORTER_OTLP_ENDPOINT", + "http://localhost:4318", + ).rstrip("/") + logs_endpoint = os.getenv( + "OTEL_EXPORTER_OTLP_LOGS_ENDPOINT", + f"{collector_endpoint}/v1/logs", + ) + traces_endpoint = os.getenv( + "OTEL_EXPORTER_OTLP_TRACES_ENDPOINT", + f"{collector_endpoint}/v1/traces", + ) + resource = Resource.create( + { + "service.name": "third-party-weather-agent", + "service.version": "1.0.0", + } + ) + provider = TracerProvider(resource=resource) + provider.add_span_processor( + RuntimeEventSpanProcessor( + endpoint=logs_endpoint, + resource=resource, + ) + ) + provider.add_span_processor( + BatchSpanProcessor( + OTLPSpanExporter(endpoint=traces_endpoint), + schedule_delay_millis=5_000, + ) + ) + + run_weather_agent(provider.get_tracer("example.third_party_genai", "1.0.0")) + provider.shutdown() + + +if __name__ == "__main__": + main() diff --git a/ioa_observe/sdk/tracing/__init__.py b/ioa_observe/sdk/tracing/__init__.py index e3820fc..09453ee 100644 --- a/ioa_observe/sdk/tracing/__init__.py +++ b/ioa_observe/sdk/tracing/__init__.py @@ -20,6 +20,14 @@ register_runtime_event_listener, unregister_runtime_event_listener, ) +from ioa_observe.sdk.tracing.runtime_event_processor import ( + GenAIRuntimeEventMapper, + InstrumentationScope, + RuntimeEventMapper, + RuntimeEventSpanProcessor, + SpanLifecycle, + SpanLifecycleObservation, +) from ioa_observe.sdk.tracing.topology import ( get_live_topology_snapshot, record_session_completed, @@ -39,6 +47,12 @@ "register_runtime_event_listener", "unregister_runtime_event_listener", "clear_runtime_event_listeners", + "GenAIRuntimeEventMapper", + "InstrumentationScope", + "RuntimeEventMapper", + "RuntimeEventSpanProcessor", + "SpanLifecycle", + "SpanLifecycleObservation", "get_live_topology_snapshot", "record_session_completed", ] diff --git a/ioa_observe/sdk/tracing/runtime_event_processor.py b/ioa_observe/sdk/tracing/runtime_event_processor.py new file mode 100644 index 0000000..af0f411 --- /dev/null +++ b/ioa_observe/sdk/tracing/runtime_event_processor.py @@ -0,0 +1,486 @@ +# Copyright AGNTCY Contributors (https://github.com/agntcy) +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import logging +import time +from collections.abc import Mapping, Sequence +from dataclasses import dataclass, replace +from datetime import datetime, timezone +from enum import Enum +from threading import RLock +from types import MappingProxyType +from typing import Any, Protocol + +from opentelemetry._logs import LogRecord, SeverityNumber +from opentelemetry.context import Context +from opentelemetry.exporter.otlp.proto.http._log_exporter import OTLPLogExporter +from opentelemetry.sdk._logs import LoggerProvider +from opentelemetry.sdk._logs.export import ( + BatchLogRecordProcessor, + LogRecordExporter, +) +from opentelemetry.sdk.resources import Resource +from opentelemetry.sdk.trace import ReadableSpan, SpanProcessor +from opentelemetry.trace import Span, SpanKind + +from ioa_observe.sdk.tracing.runtime_events import ( + RuntimeEvent, + RuntimeEventAttribute, + RuntimeEventName, +) + +_logger = logging.getLogger(__name__) + + +class SpanLifecycle(str, Enum): + START = "start" + END = "end" + + +@dataclass(frozen=True) +class InstrumentationScope: + name: str + version: str | None = None + schema_url: str | None = None + + +@dataclass(frozen=True) +class SpanLifecycleObservation: + lifecycle: SpanLifecycle + name: str + kind: SpanKind + attributes: Mapping[str, Any] + trace_id: int + span_id: int + instrumentation_scope: InstrumentationScope + resource_attributes: Mapping[str, Any] + observed_time: datetime + trace_flags: int = 0 + + def __post_init__(self) -> None: + object.__setattr__( + self, + "attributes", + MappingProxyType(dict(self.attributes)), + ) + object.__setattr__( + self, + "resource_attributes", + MappingProxyType(dict(self.resource_attributes)), + ) + + @classmethod + def from_span( + cls, + lifecycle: SpanLifecycle, + span: Any, + ) -> SpanLifecycleObservation: + span_context = span.get_span_context() + scope = span.instrumentation_scope + timestamp_ns = ( + span.start_time if lifecycle is SpanLifecycle.START else span.end_time + ) + if timestamp_ns is None: + raise ValueError(f"Span has no {lifecycle.value} timestamp") + + return cls( + lifecycle=lifecycle, + name=span.name, + kind=span.kind, + attributes=MappingProxyType(dict(span.attributes or {})), + trace_id=span_context.trace_id, + span_id=span_context.span_id, + trace_flags=int(span_context.trace_flags), + instrumentation_scope=InstrumentationScope( + name=scope.name, + version=scope.version, + schema_url=scope.schema_url, + ), + resource_attributes=MappingProxyType(dict(span.resource.attributes)), + observed_time=datetime.fromtimestamp( + timestamp_ns / 1_000_000_000, + timezone.utc, + ), + ) + + +class RuntimeEventMapper(Protocol): + def __call__( + self, + observation: SpanLifecycleObservation, + snapshot_version: int, + ) -> Sequence[RuntimeEvent]: ... + + +class RuntimeEventEmitter(Protocol): + """Emitter whose emit method must enqueue without network I/O.""" + + def emit(self, event: RuntimeEvent) -> None: ... + + def force_flush(self, timeout_millis: int = 30_000) -> bool: ... + + def shutdown(self) -> None: ... + + +class OtelLogRuntimeEventEmitter: + """Emit runtime events through an isolated, batched OTel Logs provider.""" + + def __init__( + self, + *, + endpoint: str | None = None, + headers: Mapping[str, str] | None = None, + resource: Resource | None = None, + exporter: LogRecordExporter | None = None, + max_queue_size: int = 2_048, + schedule_delay_millis: float = 100, + export_timeout_millis: float = 30_000, + ) -> None: + if max_queue_size < 1: + raise ValueError("max_queue_size must be positive") + if schedule_delay_millis <= 0: + raise ValueError("schedule_delay_millis must be positive") + + log_exporter = ( + exporter + if exporter is not None + else OTLPLogExporter( + endpoint=endpoint, + headers=dict(headers) if headers else None, + ) + ) + self._provider = LoggerProvider( + resource=resource if resource is not None else Resource.create(), + shutdown_on_exit=False, + ) + self._provider.add_log_record_processor( + BatchLogRecordProcessor( + log_exporter, + max_queue_size=max_queue_size, + max_export_batch_size=min(512, max_queue_size), + schedule_delay_millis=schedule_delay_millis, + export_timeout_millis=export_timeout_millis, + ) + ) + self._loggers: dict[tuple[str, str | None], Any] = {} + self._lock = RLock() + + def emit(self, event: RuntimeEvent) -> None: + scope_name = event.instrumentation_scope_name or "ioa_observe.runtime_events" + scope_key = (scope_name, event.instrumentation_scope_version) + with self._lock: + logger = self._loggers.get(scope_key) + if logger is None: + logger = self._provider.get_logger( + scope_name, + event.instrumentation_scope_version, + ) + self._loggers[scope_key] = logger + + event_name = ( + event.name.value if isinstance(event.name, RuntimeEventName) else event.name + ) + logger.emit( + LogRecord( + timestamp=int(event.event_time.timestamp() * 1_000_000_000), + observed_timestamp=time.time_ns(), + trace_id=event.trace_id, + span_id=event.span_id, + trace_flags=event.trace_flags, + severity_text="INFO", + severity_number=SeverityNumber.INFO, + body=event_name, + attributes=event.to_otel_attributes(), + event_name=event_name, + ) + ) + + def force_flush(self, timeout_millis: int = 30_000) -> bool: + return self._provider.force_flush(timeout_millis) + + def shutdown(self) -> None: + self._provider.shutdown() + + +class RuntimeEventSpanProcessor(SpanProcessor): + """Map span callbacks and enqueue events on a private OTel Logs pipeline.""" + + def __init__( + self, + mapper: RuntimeEventMapper | None = None, + emitter: RuntimeEventEmitter | None = None, + *, + endpoint: str | None = None, + headers: Mapping[str, str] | None = None, + resource: Resource | None = None, + exporter: LogRecordExporter | None = None, + max_queue_size: int = 2_048, + schedule_delay_millis: float = 100, + export_timeout_millis: float = 30_000, + ) -> None: + if emitter is not None and any( + ( + endpoint is not None, + headers is not None, + resource is not None, + exporter is not None, + max_queue_size != 2_048, + schedule_delay_millis != 100, + export_timeout_millis != 30_000, + ) + ): + raise ValueError( + "Transport options cannot be combined with a custom emitter" + ) + + self._mapper = mapper if mapper is not None else GenAIRuntimeEventMapper() + self._emitter = ( + emitter + if emitter is not None + else OtelLogRuntimeEventEmitter( + endpoint=endpoint, + headers=headers, + resource=resource, + exporter=exporter, + max_queue_size=max_queue_size, + schedule_delay_millis=schedule_delay_millis, + export_timeout_millis=export_timeout_millis, + ) + ) + self._lock = RLock() + self._versions: dict[str, int] = {} + self._shutdown = False + + def on_start( + self, + span: Span, + parent_context: Context | None = None, + ) -> None: + self._map_and_enqueue(SpanLifecycle.START, span) + + def on_end(self, span: ReadableSpan) -> None: + self._map_and_enqueue(SpanLifecycle.END, span) + + def force_flush(self, timeout_millis: int = 30_000) -> bool: + with self._lock: + if self._shutdown: + return True + return self._emitter.force_flush(timeout_millis) + + def shutdown(self) -> None: + with self._lock: + if self._shutdown: + return + self._shutdown = True + self._emitter.shutdown() + + def _map_and_enqueue( + self, + lifecycle: SpanLifecycle, + span: Span | ReadableSpan, + ) -> None: + try: + observation = SpanLifecycleObservation.from_span(lifecycle, span) + session_id = observation.attributes.get("gen_ai.conversation.id") + if not isinstance(session_id, str) or not session_id: + return + + with self._lock: + if self._shutdown: + return + version = self._versions.get(session_id, 0) + 1 + events = self._mapper(observation, version) + if not events: + return + sequenced_events = tuple( + replace(event, snapshot_version=version + offset) + for offset, event in enumerate(events) + ) + for event in sequenced_events: + self._emitter.emit(event) + self._versions[session_id] = event.snapshot_version + except Exception: + _logger.exception("Failed to map span lifecycle to a runtime event") + + +class GenAIRuntimeEventMapper: + """Map current OTel GenAI agent, tool, and inference spans to runtime events.""" + + _MODEL_OPERATIONS = frozenset({"chat", "generate_content", "text_completion"}) + + def __call__( + self, + observation: SpanLifecycleObservation, + snapshot_version: int, + ) -> tuple[RuntimeEvent, ...]: + if snapshot_version < 1: + raise ValueError("snapshot_version must be positive") + + session_id = observation.attributes.get("gen_ai.conversation.id") + if not isinstance(session_id, str) or not session_id: + return () + + operation = observation.attributes.get("gen_ai.operation.name") + if operation == "execute_tool": + return self._map_tool(observation, session_id, snapshot_version) + if operation == "invoke_agent": + return self._map_agent(observation, session_id, snapshot_version) + if operation in self._MODEL_OPERATIONS: + return self._map_model( + observation, + session_id, + snapshot_version, + operation, + ) + return () + + @staticmethod + def _map_agent( + observation: SpanLifecycleObservation, + session_id: str, + snapshot_version: int, + ) -> tuple[RuntimeEvent, ...]: + if observation.kind not in {SpanKind.INTERNAL, SpanKind.CLIENT}: + return () + agent_name = observation.attributes.get("gen_ai.agent.name") + if not isinstance(agent_name, str) or not agent_name: + return () + provider_name = observation.attributes.get("gen_ai.provider.name") + if observation.kind is SpanKind.CLIENT and ( + not isinstance(provider_name, str) or not provider_name + ): + return () + + event_name = ( + RuntimeEventName.TOPOLOGY_NODE_STARTED + if observation.lifecycle is SpanLifecycle.START + else RuntimeEventName.TOPOLOGY_NODE_COMPLETED + ) + attributes = { + RuntimeEventAttribute.AGENT_NAME.value: agent_name, + "gen_ai.operation.name": "invoke_agent", + } + if isinstance(provider_name, str) and provider_name: + attributes["gen_ai.provider.name"] = provider_name + event = RuntimeEvent( + name=event_name, + session_id=session_id, + snapshot_version=snapshot_version, + event_time=observation.observed_time, + attributes=attributes, + trace_id=observation.trace_id, + span_id=observation.span_id, + trace_flags=observation.trace_flags, + instrumentation_scope_name=observation.instrumentation_scope.name, + instrumentation_scope_version=observation.instrumentation_scope.version, + ) + event.to_otel_attributes() + return (event,) + + @staticmethod + def _map_tool( + observation: SpanLifecycleObservation, + session_id: str, + snapshot_version: int, + ) -> tuple[RuntimeEvent, ...]: + if observation.kind is not SpanKind.INTERNAL: + return () + tool_name = observation.attributes.get("gen_ai.tool.name") + if not isinstance(tool_name, str) or not tool_name: + return () + + attributes: dict[str, Any] = { + RuntimeEventAttribute.TOOL_NAME.value: tool_name, + "gen_ai.operation.name": "execute_tool", + } + optional_mappings = { + "gen_ai.tool.call.id": "gen_ai.tool.call.id", + "gen_ai.tool.call.arguments": RuntimeEventAttribute.TOOL_INPUT.value, + "gen_ai.tool.call.result": RuntimeEventAttribute.TOOL_OUTPUT.value, + "gen_ai.agent.name": "gen_ai.agent.name", + } + for source, target in optional_mappings.items(): + value = observation.attributes.get(source) + if value is not None: + attributes[target] = value + + event_name = ( + RuntimeEventName.TOOL_STARTED + if observation.lifecycle is SpanLifecycle.START + else RuntimeEventName.TOOL_COMPLETED + ) + event = RuntimeEvent( + name=event_name, + session_id=session_id, + snapshot_version=snapshot_version, + event_time=observation.observed_time, + attributes=attributes, + trace_id=observation.trace_id, + span_id=observation.span_id, + trace_flags=observation.trace_flags, + instrumentation_scope_name=observation.instrumentation_scope.name, + instrumentation_scope_version=observation.instrumentation_scope.version, + ) + event.to_otel_attributes() + return (event,) + + @staticmethod + def _map_model( + observation: SpanLifecycleObservation, + session_id: str, + snapshot_version: int, + operation: str, + ) -> tuple[RuntimeEvent, ...]: + if observation.kind not in {SpanKind.CLIENT, SpanKind.INTERNAL}: + return () + provider_name = observation.attributes.get("gen_ai.provider.name") + if not isinstance(provider_name, str) or not provider_name: + return () + + request_model = observation.attributes.get("gen_ai.request.model") + response_model = observation.attributes.get("gen_ai.response.model") + model_name = ( + request_model + if observation.lifecycle is SpanLifecycle.START + else response_model or request_model + ) + if not isinstance(model_name, str) or not model_name: + return () + + attributes: dict[str, Any] = { + RuntimeEventAttribute.LLM_NAME.value: model_name, + RuntimeEventAttribute.LLM_CALL_ID.value: f"{observation.span_id:016x}", + "gen_ai.operation.name": operation, + "gen_ai.provider.name": provider_name, + } + optional_mappings = { + "gen_ai.input.messages": RuntimeEventAttribute.LLM_INPUT.value, + "gen_ai.output.messages": RuntimeEventAttribute.LLM_OUTPUT.value, + "gen_ai.response.id": "gen_ai.response.id", + } + for source, target in optional_mappings.items(): + value = observation.attributes.get(source) + if value is not None: + attributes[target] = value + + event_name = ( + RuntimeEventName.LLM_STARTED + if observation.lifecycle is SpanLifecycle.START + else RuntimeEventName.LLM_COMPLETED + ) + event = RuntimeEvent( + name=event_name, + session_id=session_id, + snapshot_version=snapshot_version, + event_time=observation.observed_time, + attributes=attributes, + trace_id=observation.trace_id, + span_id=observation.span_id, + trace_flags=observation.trace_flags, + instrumentation_scope_name=observation.instrumentation_scope.name, + instrumentation_scope_version=observation.instrumentation_scope.version, + ) + event.to_otel_attributes() + return (event,) diff --git a/ioa_observe/sdk/tracing/runtime_events.py b/ioa_observe/sdk/tracing/runtime_events.py index f9ab17c..4325244 100644 --- a/ioa_observe/sdk/tracing/runtime_events.py +++ b/ioa_observe/sdk/tracing/runtime_events.py @@ -121,6 +121,11 @@ class RuntimeEvent: snapshot_version: int event_time: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) attributes: Mapping[str, Any] = field(default_factory=dict) + trace_id: int | None = None + span_id: int | None = None + trace_flags: int | None = None + instrumentation_scope_name: str | None = None + instrumentation_scope_version: str | None = None def to_otel_attributes(self) -> dict[str, str | bool | int | float]: event_name = ( diff --git a/tests/test_runtime_event_processor.py b/tests/test_runtime_event_processor.py new file mode 100644 index 0000000..3a2a031 --- /dev/null +++ b/tests/test_runtime_event_processor.py @@ -0,0 +1,267 @@ +# Copyright AGNTCY Contributors (https://github.com/agntcy) +# SPDX-License-Identifier: Apache-2.0 + +from datetime import datetime, timezone + +import pytest +from opentelemetry.sdk.resources import Resource +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.trace import SpanKind + +from ioa_observe.sdk.tracing.runtime_event_processor import ( + GenAIRuntimeEventMapper, + InstrumentationScope, + SpanLifecycle, + SpanLifecycleObservation, +) +from ioa_observe.sdk.tracing.runtime_events import ( + RuntimeEventAttribute, + RuntimeEventName, +) + + +def observation( + lifecycle: SpanLifecycle, + attributes: dict, + *, + kind: SpanKind = SpanKind.INTERNAL, + span_id: int = 2, +) -> SpanLifecycleObservation: + return SpanLifecycleObservation( + lifecycle=lifecycle, + name="third-party GenAI operation", + kind=kind, + attributes=attributes, + trace_id=1, + span_id=span_id, + instrumentation_scope=InstrumentationScope("third.party.genai", "1.2.3"), + resource_attributes={"service.name": "third-party-agent"}, + observed_time=datetime(2026, 9, 9, tzinfo=timezone.utc), + ) + + +def test_observation_captures_public_otel_correlation_as_immutable_snapshot(): + provider = TracerProvider( + resource=Resource.create({"service.name": "third-party-agent"}) + ) + tracer = provider.get_tracer("third.party.genai", "1.2.3") + span = tracer.start_span( + "invoke_agent Weather Agent", + attributes={ + "gen_ai.operation.name": "invoke_agent", + "gen_ai.conversation.id": "conversation-123", + }, + ) + + captured = SpanLifecycleObservation.from_span(SpanLifecycle.START, span) + span.set_attribute("gen_ai.conversation.id", "changed") + + assert captured.trace_id == span.get_span_context().trace_id + assert captured.span_id == span.get_span_context().span_id + assert captured.instrumentation_scope.name == "third.party.genai" + assert captured.instrumentation_scope.version == "1.2.3" + assert captured.resource_attributes["service.name"] == "third-party-agent" + assert captured.attributes["gen_ai.conversation.id"] == "conversation-123" + with pytest.raises(TypeError): + captured.attributes["new"] = "value" + + span.end() + + +def test_genai_agent_invocation_maps_to_topology_node_lifecycle(): + mapper = GenAIRuntimeEventMapper() + attributes = { + "gen_ai.operation.name": "invoke_agent", + "gen_ai.conversation.id": "conversation-123", + "gen_ai.agent.name": "Weather Agent", + } + + started = mapper(observation(SpanLifecycle.START, attributes), 1)[0] + completed = mapper(observation(SpanLifecycle.END, attributes), 2)[0] + + assert started.name == RuntimeEventName.TOPOLOGY_NODE_STARTED + assert completed.name == RuntimeEventName.TOPOLOGY_NODE_COMPLETED + assert started.session_id == "conversation-123" + assert started.attributes == { + RuntimeEventAttribute.AGENT_NAME.value: "Weather Agent", + "gen_ai.operation.name": "invoke_agent", + } + assert ( + started.to_otel_attributes()[RuntimeEventAttribute.SNAPSHOT_VERSION.value] == 1 + ) + + +def test_genai_tool_execution_maps_standardized_input_and_output(): + mapper = GenAIRuntimeEventMapper() + attributes = { + "gen_ai.operation.name": "execute_tool", + "gen_ai.conversation.id": "conversation-123", + "gen_ai.tool.name": "get_weather", + "gen_ai.tool.call.id": "call-123", + "gen_ai.tool.call.arguments": '{"city":"Paris"}', + } + + started = mapper(observation(SpanLifecycle.START, attributes), 1)[0] + completed = mapper( + observation( + SpanLifecycle.END, + {**attributes, "gen_ai.tool.call.result": '{"temperature":22}'}, + ), + 2, + )[0] + + assert started.name == RuntimeEventName.TOOL_STARTED + assert started.attributes[RuntimeEventAttribute.TOOL_NAME.value] == "get_weather" + assert ( + started.attributes[RuntimeEventAttribute.TOOL_INPUT.value] == '{"city":"Paris"}' + ) + assert completed.name == RuntimeEventName.TOOL_COMPLETED + assert ( + completed.attributes[RuntimeEventAttribute.TOOL_OUTPUT.value] + == '{"temperature":22}' + ) + + +def test_genai_model_inference_maps_standardized_model_and_messages(): + mapper = GenAIRuntimeEventMapper() + attributes = { + "gen_ai.operation.name": "chat", + "gen_ai.provider.name": "openai", + "gen_ai.conversation.id": "conversation-123", + "gen_ai.request.model": "gpt-5", + "gen_ai.input.messages": '[{"role":"user","parts":[]}]', + } + + started = mapper( + observation( + SpanLifecycle.START, + attributes, + kind=SpanKind.CLIENT, + span_id=0xABC, + ), + 1, + )[0] + completed = mapper( + observation( + SpanLifecycle.END, + { + **attributes, + "gen_ai.response.model": "gpt-5-2026-08-01", + "gen_ai.response.id": "response-123", + "gen_ai.output.messages": '[{"role":"assistant","parts":[]}]', + }, + kind=SpanKind.CLIENT, + span_id=0xABC, + ), + 2, + )[0] + + assert started.name == RuntimeEventName.LLM_STARTED + assert started.attributes[RuntimeEventAttribute.LLM_NAME.value] == "gpt-5" + assert ( + started.attributes[RuntimeEventAttribute.LLM_INPUT.value] + == '[{"role":"user","parts":[]}]' + ) + assert started.attributes[RuntimeEventAttribute.LLM_CALL_ID.value] == ( + "0000000000000abc" + ) + assert completed.name == RuntimeEventName.LLM_COMPLETED + assert completed.attributes[RuntimeEventAttribute.LLM_NAME.value] == ( + "gpt-5-2026-08-01" + ) + assert completed.attributes[RuntimeEventAttribute.LLM_OUTPUT.value] == ( + '[{"role":"assistant","parts":[]}]' + ) + + +def test_late_genai_attributes_can_map_completion_but_not_start(): + mapper = GenAIRuntimeEventMapper() + start = observation( + SpanLifecycle.START, + {"gen_ai.conversation.id": "conversation-123"}, + ) + end = observation( + SpanLifecycle.END, + { + "gen_ai.operation.name": "execute_tool", + "gen_ai.conversation.id": "conversation-123", + "gen_ai.tool.name": "get_weather", + }, + ) + + assert mapper(start, 1) == () + assert mapper(end, 2)[0].name == RuntimeEventName.TOOL_COMPLETED + + +@pytest.mark.parametrize( + ("attributes", "kind"), + [ + ( + { + "llm.request.type": "chat", + "session.id": "legacy-session", + "gen_ai.request.model": "gpt-5", + }, + SpanKind.CLIENT, + ), + ( + { + "gen_ai.operation.name": "embeddings", + "gen_ai.provider.name": "openai", + "gen_ai.conversation.id": "conversation-123", + "gen_ai.request.model": "text-embedding-3-small", + }, + SpanKind.CLIENT, + ), + ( + { + "gen_ai.operation.name": "execute_tool", + "gen_ai.conversation.id": "conversation-123", + "gen_ai.tool.name": "get_weather", + }, + SpanKind.CLIENT, + ), + ( + { + "gen_ai.operation.name": "chat", + "gen_ai.conversation.id": "conversation-123", + "gen_ai.request.model": "gpt-5", + }, + SpanKind.CLIENT, + ), + ( + { + "gen_ai.operation.name": "invoke_agent", + "gen_ai.agent.name": "Weather Agent", + }, + SpanKind.INTERNAL, + ), + ( + { + "gen_ai.operation.name": "invoke_agent", + "gen_ai.conversation.id": "conversation-123", + "gen_ai.agent.name": "Weather Agent", + }, + SpanKind.CLIENT, + ), + ], +) +def test_noncompliant_or_unsupported_spans_emit_nothing(attributes, kind): + mapper = GenAIRuntimeEventMapper() + + assert mapper(observation(SpanLifecycle.START, attributes, kind=kind), 1) == () + + +def test_mapper_rejects_non_positive_snapshot_version(): + with pytest.raises(ValueError, match="snapshot_version must be positive"): + GenAIRuntimeEventMapper()( + observation( + SpanLifecycle.START, + { + "gen_ai.operation.name": "invoke_agent", + "gen_ai.conversation.id": "conversation-123", + "gen_ai.agent.name": "Weather Agent", + }, + ), + 0, + ) diff --git a/tests/test_runtime_event_span_processor.py b/tests/test_runtime_event_span_processor.py new file mode 100644 index 0000000..9e39e30 --- /dev/null +++ b/tests/test_runtime_event_span_processor.py @@ -0,0 +1,79 @@ +# Copyright AGNTCY Contributors (https://github.com/agntcy) +# SPDX-License-Identifier: Apache-2.0 + +from opentelemetry._logs import get_logger_provider +from opentelemetry.sdk._logs.export import ( + LogRecordExporter, + LogRecordExportResult, +) +from opentelemetry.sdk.resources import Resource +from opentelemetry.sdk.trace import TracerProvider + +from ioa_observe.sdk.tracing import ( + RuntimeEventAttribute, + RuntimeEventName, + RuntimeEventSpanProcessor, +) + + +class MemoryLogExporter(LogRecordExporter): + def __init__(self): + self.records = [] + + def export(self, batch): + self.records.extend(batch) + return LogRecordExportResult.SUCCESS + + def shutdown(self): + pass + + +def test_processor_exports_correlated_event_logs_without_replacing_global_provider(): + global_logger_provider = get_logger_provider() + resource = Resource.create({"service.name": "third-party-agent"}) + exporter = MemoryLogExporter() + provider = TracerProvider() + processor = RuntimeEventSpanProcessor( + exporter=exporter, + resource=resource, + schedule_delay_millis=10, + ) + provider.add_span_processor(processor) + tracer = provider.get_tracer("third.party.genai", "1.2.3") + + with tracer.start_as_current_span( + "invoke_agent Weather Agent", + attributes={ + "gen_ai.operation.name": "invoke_agent", + "gen_ai.conversation.id": "conversation-123", + "gen_ai.agent.name": "Weather Agent", + }, + ) as span: + span_context = span.get_span_context() + + assert processor.force_flush(timeout_millis=1_000) + assert get_logger_provider() is global_logger_provider + assert [record.log_record.event_name for record in exporter.records] == [ + RuntimeEventName.TOPOLOGY_NODE_STARTED.value, + RuntimeEventName.TOPOLOGY_NODE_COMPLETED.value, + ] + assert [ + record.log_record.attributes[RuntimeEventAttribute.SNAPSHOT_VERSION.value] + for record in exporter.records + ] == [1, 2] + assert all( + record.log_record.trace_id == span_context.trace_id + and record.log_record.span_id == span_context.span_id + for record in exporter.records + ) + assert all( + record.instrumentation_scope.name == "third.party.genai" + and record.instrumentation_scope.version == "1.2.3" + for record in exporter.records + ) + assert all( + record.resource.attributes["service.name"] == "third-party-agent" + for record in exporter.records + ) + + provider.shutdown() From cf887f8fba0962833c20b13fa77933bce778c204 Mon Sep 17 00:00:00 2001 From: Pavan Sudheendra Date: Thu, 10 Sep 2026 16:24:09 +0100 Subject: [PATCH 2/3] feat: implement end-end agent output capture topology.node.completed now includes agent.output, topology listeners expose agent_output, third-party GenAI spans map gen_ai.output.messages, and materialized nodes store output. Schema, docs, and regression coverage were updated. Signed-off-by: Pavan Sudheendra --- ioa_observe/materializer/session_state.py | 6 ++++++ ioa_observe/sdk/decorators/base.py | 6 +++++- ioa_observe/sdk/tracing/runtime_event_processor.py | 4 ++++ ioa_observe/sdk/tracing/runtime_events.py | 1 + ioa_observe/sdk/tracing/topology.py | 10 ++++++++-- tests/test_runtime_event_processor.py | 14 +++++++++++++- tests/test_runtime_events.py | 12 ++++++++++++ tests/test_session_materializer.py | 6 +++++- tests/test_topology_events.py | 12 ++++++++++++ 9 files changed, 66 insertions(+), 5 deletions(-) diff --git a/ioa_observe/materializer/session_state.py b/ioa_observe/materializer/session_state.py index 1de2582..702d9d7 100644 --- a/ioa_observe/materializer/session_state.py +++ b/ioa_observe/materializer/session_state.py @@ -79,6 +79,7 @@ class SessionNodeState: started_at: datetime | None = None completed_at: datetime | None = None input: str | None = None + output: str | None = None @dataclass @@ -321,6 +322,11 @@ def _apply_node_event( node.status = "completed" node.started_at = node.started_at or record.event_time node.completed_at = record.event_time + agent_output = _optional_attribute( + record, RuntimeEventAttribute.AGENT_OUTPUT.value + ) + if agent_output is not None: + node.output = agent_output def _apply_topology_edge_event( self, diff --git a/ioa_observe/sdk/decorators/base.py b/ioa_observe/sdk/decorators/base.py index 8ad7b47..d3c9a11 100644 --- a/ioa_observe/sdk/decorators/base.py +++ b/ioa_observe/sdk/decorators/base.py @@ -615,7 +615,11 @@ def _cleanup_span(span, ctx_token): unregister_active_span(session_id, agent_seq) agent_name = getattr(span, "_ioa_agent_name", None) if agent_name: - record_node_completed(session_id, agent_name) + record_node_completed( + session_id, + agent_name, + agent_output=span.attributes.get(OBSERVE_ENTITY_OUTPUT), + ) # Mark tool as no longer in-flight for tool-level fork detection tool_parent_hex = getattr(span, "_ioa_tool_parent_hex", None) diff --git a/ioa_observe/sdk/tracing/runtime_event_processor.py b/ioa_observe/sdk/tracing/runtime_event_processor.py index af0f411..a6c1b53 100644 --- a/ioa_observe/sdk/tracing/runtime_event_processor.py +++ b/ioa_observe/sdk/tracing/runtime_event_processor.py @@ -362,6 +362,10 @@ def _map_agent( RuntimeEventAttribute.AGENT_NAME.value: agent_name, "gen_ai.operation.name": "invoke_agent", } + if observation.lifecycle is SpanLifecycle.END: + agent_output = observation.attributes.get("gen_ai.output.messages") + if agent_output is not None: + attributes[RuntimeEventAttribute.AGENT_OUTPUT.value] = agent_output if isinstance(provider_name, str) and provider_name: attributes["gen_ai.provider.name"] = provider_name event = RuntimeEvent( diff --git a/ioa_observe/sdk/tracing/runtime_events.py b/ioa_observe/sdk/tracing/runtime_events.py index 4325244..0eb20b5 100644 --- a/ioa_observe/sdk/tracing/runtime_events.py +++ b/ioa_observe/sdk/tracing/runtime_events.py @@ -34,6 +34,7 @@ class RuntimeEventAttribute(str, Enum): SNAPSHOT_VERSION = "snapshot.version" AGENT_NAME = "agent.name" AGENT_INPUT = "agent.input" + AGENT_OUTPUT = "agent.output" TOOL_NAME = "tool.name" TOOL_INPUT = "tool.input" TOOL_OUTPUT = "tool.output" diff --git a/ioa_observe/sdk/tracing/topology.py b/ioa_observe/sdk/tracing/topology.py index 71af913..97439fd 100644 --- a/ioa_observe/sdk/tracing/topology.py +++ b/ioa_observe/sdk/tracing/topology.py @@ -168,7 +168,9 @@ def record_node_started( _publish(event, runtime_attributes) -def record_node_completed(session_id: str, agent_name: str) -> None: +def record_node_completed( + session_id: str, agent_name: str, agent_output: str | None = None +) -> None: now_ms = _now_ms() with _lock: graph = _get_or_create_graph(session_id) @@ -191,6 +193,7 @@ def record_node_completed(session_id: str, agent_name: str) -> None: "type": RuntimeEventName.TOPOLOGY_NODE_COMPLETED.value, "session_id": session_id, "agent_name": agent_name, + "agent_output": agent_output, "snapshot_version": graph.version, "snapshot": graph.snapshot(), } @@ -198,7 +201,10 @@ def record_node_completed(session_id: str, agent_name: str) -> None: RuntimeEventName.TOPOLOGY_NODE_COMPLETED, session_id=session_id, snapshot_version=graph.version, - **{RuntimeEventAttribute.AGENT_NAME.value: agent_name}, + **{ + RuntimeEventAttribute.AGENT_NAME.value: agent_name, + RuntimeEventAttribute.AGENT_OUTPUT.value: agent_output, + }, ) _publish(event, runtime_attributes) diff --git a/tests/test_runtime_event_processor.py b/tests/test_runtime_event_processor.py index 3a2a031..3d5ba3b 100644 --- a/tests/test_runtime_event_processor.py +++ b/tests/test_runtime_event_processor.py @@ -77,7 +77,16 @@ def test_genai_agent_invocation_maps_to_topology_node_lifecycle(): } started = mapper(observation(SpanLifecycle.START, attributes), 1)[0] - completed = mapper(observation(SpanLifecycle.END, attributes), 2)[0] + completed = mapper( + observation( + SpanLifecycle.END, + { + **attributes, + "gen_ai.output.messages": '[{"role":"assistant","parts":[]}]', + }, + ), + 2, + )[0] assert started.name == RuntimeEventName.TOPOLOGY_NODE_STARTED assert completed.name == RuntimeEventName.TOPOLOGY_NODE_COMPLETED @@ -89,6 +98,9 @@ def test_genai_agent_invocation_maps_to_topology_node_lifecycle(): assert ( started.to_otel_attributes()[RuntimeEventAttribute.SNAPSHOT_VERSION.value] == 1 ) + assert completed.attributes[RuntimeEventAttribute.AGENT_OUTPUT.value] == ( + '[{"role":"assistant","parts":[]}]' + ) def test_genai_tool_execution_maps_standardized_input_and_output(): diff --git a/tests/test_runtime_events.py b/tests/test_runtime_events.py index 6759f35..b436717 100644 --- a/tests/test_runtime_events.py +++ b/tests/test_runtime_events.py @@ -225,6 +225,18 @@ def test_agent_lifecycle_pushes_runtime_events(runtime_events): } } assert {"runtime_planner", "runtime_executor"}.issubset(agent_names) + completed_events = { + event[RuntimeEventAttribute.AGENT_NAME.value]: event + for event in runtime_events + if event[RuntimeEventAttribute.EVENT_NAME.value] + == RuntimeEventName.TOPOLOGY_NODE_COMPLETED.value + } + assert json.loads( + completed_events["runtime_planner"][RuntimeEventAttribute.AGENT_OUTPUT.value] + ) == {"planned": "draft"} + assert json.loads( + completed_events["runtime_executor"][RuntimeEventAttribute.AGENT_OUTPUT.value] + ) == {"result": "draft"} def test_a2a_helpers_push_runtime_events(runtime_events): diff --git a/tests/test_session_materializer.py b/tests/test_session_materializer.py index 7aa60a5..c395f40 100644 --- a/tests/test_session_materializer.py +++ b/tests/test_session_materializer.py @@ -79,7 +79,10 @@ def test_materializer_builds_live_session_state_from_runtime_events(): base_time + timedelta(seconds=5), session_id="session-123", snapshot_version=5, - **{RuntimeEventAttribute.AGENT_NAME.value: "planner"}, + **{ + RuntimeEventAttribute.AGENT_NAME.value: "planner", + RuntimeEventAttribute.AGENT_OUTPUT.value: "plan", + }, ), _event( RuntimeEventName.TOOL_COMPLETED, @@ -124,6 +127,7 @@ def test_materializer_builds_live_session_state_from_runtime_events(): nodes = {node["id"]: node for node in snapshot["nodes"]} assert nodes["planner"]["status"] == "completed" assert nodes["planner"]["version"] == 5 + assert nodes["planner"]["output"] == "plan" edges = {edge["id"]: edge for edge in snapshot["edges"]} assert edges["agent_handoff:planner->executor"]["status"] == "observed" diff --git a/tests/test_topology_events.py b/tests/test_topology_events.py index c0103b7..ef9d1a2 100644 --- a/tests/test_topology_events.py +++ b/tests/test_topology_events.py @@ -1,6 +1,7 @@ # Copyright AGNTCY Contributors (https://github.com/agntcy) # SPDX-License-Identifier: Apache-2.0 +import json from types import SimpleNamespace import pytest @@ -101,6 +102,17 @@ def test_agent_events_build_runtime_snapshot(topology_events): assert "topology.node.started" in event_types assert "topology.node.completed" in event_types assert "topology.edge.updated" in event_types + completed_events = { + event["agent_name"]: event + for event in topology_events + if event["type"] == "topology.node.completed" + } + assert json.loads(completed_events["planner"]["agent_output"]) == { + "planned": "draft" + } + assert json.loads(completed_events["executor"]["agent_output"]) == { + "result": "draft" + } def test_a2a_send_and_receive_emit_live_edge_events(topology_events): From 5a364f44bce1a6613d804a8be8fe674804d868b6 Mon Sep 17 00:00:00 2001 From: Pavan Sudheendra Date: Fri, 11 Sep 2026 09:35:00 +0100 Subject: [PATCH 3/3] feat: bump to 1.0.46 Signed-off-by: Pavan Sudheendra --- examples/realtime_demo/README.md | 28 ++++++++++++++++++++++++++++ pyproject.toml | 2 +- uv.lock | 32 ++++++++++++++++---------------- 3 files changed, 45 insertions(+), 17 deletions(-) diff --git a/examples/realtime_demo/README.md b/examples/realtime_demo/README.md index 47f702d..7910181 100644 --- a/examples/realtime_demo/README.md +++ b/examples/realtime_demo/README.md @@ -32,6 +32,34 @@ No external OTel Collector or ClickHouse is required. The demo attaches the `SessionStateMaterializer` in-process via the runtime-event listener so it runs with a single command. +## Third-party OTel SDK mapping example + +`third_party_otel.py` creates and owns its own `TracerProvider`, instruments +agent, model, and tool spans using current `gen_ai.*` semantic-convention +attributes, and sends traces and mapped runtime-event logs to an OTLP/HTTP +collector: + +```bash +uv run python examples/realtime_demo/third_party_otel.py +``` + +Start an OTLP/HTTP collector on port `4318` before running this example. +The endpoint defaults to `http://localhost:4318` and honors +`OTEL_EXPORTER_OTLP_ENDPOINT`, `OTEL_EXPORTER_OTLP_LOGS_ENDPOINT`, and +`OTEL_EXPORTER_OTLP_TRACES_ENDPOINT`. + +The application keeps ownership of its provider and existing span processor. +Observe's processor owns the default GenAI mapper and private Logs pipeline: + +```python +provider.add_span_processor( + RuntimeEventSpanProcessor( + endpoint="http://localhost:4318/v1/logs", + resource=resource, + ) +) +``` + ## How it maps to production In production the materializer lives **downstream** and consumes the same OTel diff --git a/pyproject.toml b/pyproject.toml index e6fdd84..2794f93 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta" [project] name = "ioa-observe-sdk" -version = "1.0.45" +version = "1.0.46" license = "Apache-2.0" description = "IOA Observability SDK" readme = "README.md" diff --git a/uv.lock b/uv.lock index 9452a37..6601f6f 100644 --- a/uv.lock +++ b/uv.lock @@ -5,12 +5,12 @@ resolution-markers = [ "python_full_version >= '3.13' and platform_python_implementation == 'PyPy'", "python_full_version >= '3.13' and platform_python_implementation != 'PyPy'", "python_full_version >= '3.12.4' and python_full_version < '3.13' and platform_python_implementation != 'PyPy'", - "python_full_version >= '3.12' and python_full_version < '3.12.4' and platform_python_implementation != 'PyPy'", - "python_full_version == '3.11.*' and platform_python_implementation != 'PyPy'", - "python_full_version < '3.11' and platform_python_implementation != 'PyPy'", "python_full_version >= '3.12.4' and python_full_version < '3.13' and platform_python_implementation == 'PyPy'", + "python_full_version >= '3.12' and python_full_version < '3.12.4' and platform_python_implementation != 'PyPy'", "python_full_version >= '3.12' and python_full_version < '3.12.4' and platform_python_implementation == 'PyPy'", + "python_full_version == '3.11.*' and platform_python_implementation != 'PyPy'", "python_full_version == '3.11.*' and platform_python_implementation == 'PyPy'", + "python_full_version < '3.11' and platform_python_implementation != 'PyPy'", "python_full_version < '3.11' and platform_python_implementation == 'PyPy'", ] @@ -232,7 +232,7 @@ name = "cffi" version = "1.17.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pycparser", marker = "platform_python_implementation == 'PyPy'" }, + { name = "pycparser" }, ] sdist = { url = "https://files.pythonhosted.org/packages/fc/97/c783634659c2920c3fc70419e3af40972dbaf758daa229a7d6ea6135c90d/cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824", size = 516621, upload-time = "2024-09-04T20:45:21.852Z" } wheels = [ @@ -745,7 +745,7 @@ wheels = [ [[package]] name = "ioa-observe-sdk" -version = "1.0.45" +version = "1.0.46" source = { editable = "." } dependencies = [ { name = "colorama" }, @@ -1491,10 +1491,10 @@ resolution-markers = [ "python_full_version >= '3.13' and platform_python_implementation == 'PyPy'", "python_full_version >= '3.13' and platform_python_implementation != 'PyPy'", "python_full_version >= '3.12.4' and python_full_version < '3.13' and platform_python_implementation != 'PyPy'", - "python_full_version >= '3.12' and python_full_version < '3.12.4' and platform_python_implementation != 'PyPy'", - "python_full_version == '3.11.*' and platform_python_implementation != 'PyPy'", "python_full_version >= '3.12.4' and python_full_version < '3.13' and platform_python_implementation == 'PyPy'", + "python_full_version >= '3.12' and python_full_version < '3.12.4' and platform_python_implementation != 'PyPy'", "python_full_version >= '3.12' and python_full_version < '3.12.4' and platform_python_implementation == 'PyPy'", + "python_full_version == '3.11.*' and platform_python_implementation != 'PyPy'", "python_full_version == '3.11.*' and platform_python_implementation == 'PyPy'", ] sdist = { url = "https://files.pythonhosted.org/packages/6c/4f/ccdb8ad3a38e583f214547fd2f7ff1fc160c43a75af88e6aec213404b96a/networkx-3.5.tar.gz", hash = "sha256:d4c6f9cf81f52d69230866796b82afbccdec3db7ae4fbd1b65ea750feed50037", size = 2471065, upload-time = "2025-05-29T11:35:07.804Z" } @@ -1591,10 +1591,10 @@ resolution-markers = [ "python_full_version >= '3.13' and platform_python_implementation == 'PyPy'", "python_full_version >= '3.13' and platform_python_implementation != 'PyPy'", "python_full_version >= '3.12.4' and python_full_version < '3.13' and platform_python_implementation != 'PyPy'", - "python_full_version >= '3.12' and python_full_version < '3.12.4' and platform_python_implementation != 'PyPy'", - "python_full_version == '3.11.*' and platform_python_implementation != 'PyPy'", "python_full_version >= '3.12.4' and python_full_version < '3.13' and platform_python_implementation == 'PyPy'", + "python_full_version >= '3.12' and python_full_version < '3.12.4' and platform_python_implementation != 'PyPy'", "python_full_version >= '3.12' and python_full_version < '3.12.4' and platform_python_implementation == 'PyPy'", + "python_full_version == '3.11.*' and platform_python_implementation != 'PyPy'", "python_full_version == '3.11.*' and platform_python_implementation == 'PyPy'", ] sdist = { url = "https://files.pythonhosted.org/packages/f3/db/8e12381333aea300890829a0a36bfa738cac95475d88982d538725143fd9/numpy-2.3.0.tar.gz", hash = "sha256:581f87f9e9e9db2cba2141400e160e9dd644ee248788d6f90636eeb8fd9260a6", size = 20382813, upload-time = "2025-06-07T14:54:32.608Z" } @@ -3058,9 +3058,9 @@ resolution-markers = [ "python_full_version < '3.11' and platform_python_implementation == 'PyPy'", ] dependencies = [ - { name = "pyyaml", marker = "platform_python_implementation == 'PyPy'" }, - { name = "wrapt", marker = "platform_python_implementation == 'PyPy'" }, - { name = "yarl", marker = "platform_python_implementation == 'PyPy'" }, + { name = "pyyaml" }, + { name = "wrapt" }, + { name = "yarl" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a5/ea/a166a3cce4ac5958ba9bbd9768acdb1ba38ae17ff7986da09fa5b9dbc633/vcrpy-5.1.0.tar.gz", hash = "sha256:bbf1532f2618a04f11bce2a99af3a9647a32c880957293ff91e0a5f187b6b3d2", size = 84576, upload-time = "2023-07-31T03:19:32.231Z" } wheels = [ @@ -3079,10 +3079,10 @@ resolution-markers = [ "python_full_version < '3.11' and platform_python_implementation != 'PyPy'", ] dependencies = [ - { name = "pyyaml", marker = "platform_python_implementation != 'PyPy'" }, - { name = "urllib3", marker = "platform_python_implementation != 'PyPy'" }, - { name = "wrapt", marker = "platform_python_implementation != 'PyPy'" }, - { name = "yarl", marker = "platform_python_implementation != 'PyPy'" }, + { name = "pyyaml" }, + { name = "urllib3" }, + { name = "wrapt" }, + { name = "yarl" }, ] sdist = { url = "https://files.pythonhosted.org/packages/25/d3/856e06184d4572aada1dd559ddec3bedc46df1f2edc5ab2c91121a2cccdb/vcrpy-7.0.0.tar.gz", hash = "sha256:176391ad0425edde1680c5b20738ea3dc7fb942520a48d2993448050986b3a50", size = 85502, upload-time = "2024-12-31T00:07:57.894Z" } wheels = [