Files
microsoft--agent-lightning/tests/emitter/test_operation.py
T
wehub-resource-sync 85742ab165
Deploy Documentation / deploy (push) Has been cancelled
CPU Test / Test (Utilities, legacy, Python 3.10) (push) Has been cancelled
CPU Test / Test (LLM proxy, stable, Python 3.11) (push) Has been cancelled
CPU Test / Test (Others, stable, Python 3.11) (push) Has been cancelled
CPU Test / Test (Store, stable, Python 3.11) (push) Has been cancelled
CPU Test / Test (Utilities, stable, Python 3.11) (push) Has been cancelled
CPU Test / Test (Weave, stable, Python 3.11) (push) Has been cancelled
CPU Test / Test (AgentOps, stable, Python 3.12) (push) Has been cancelled
CPU Test / Test (LLM proxy, stable, Python 3.12) (push) Has been cancelled
CPU Test / Test (Others, stable, Python 3.12) (push) Has been cancelled
CPU Test / Test (Weave, latest, Python 3.13) (push) Has been cancelled
Dashboard / Chromatic (push) Has been cancelled
CPU Test / Lint - fast (push) Has been cancelled
CPU Test / Lint - next (push) Has been cancelled
CPU Test / Lint - slow (push) Has been cancelled
CPU Test / Lint - JavaScript (push) Has been cancelled
CPU Test / Build documentation (push) Has been cancelled
CPU Test / Test (AgentOps, legacy, Python 3.10) (push) Has been cancelled
CPU Test / Test (LLM proxy, legacy, Python 3.10) (push) Has been cancelled
CPU Test / Test (Others, legacy, Python 3.10) (push) Has been cancelled
CPU Test / Test (Store, legacy, Python 3.10) (push) Has been cancelled
CPU Test / Test (Weave, legacy, Python 3.10) (push) Has been cancelled
CPU Test / Test (AgentOps, stable, Python 3.11) (push) Has been cancelled
CPU Test / Test (Store, stable, Python 3.12) (push) Has been cancelled
CPU Test / Test (Utilities, stable, Python 3.12) (push) Has been cancelled
CPU Test / Test (Weave, stable, Python 3.12) (push) Has been cancelled
CPU Test / Test (AgentOps, latest, Python 3.13) (push) Has been cancelled
CPU Test / Test (LLM proxy, latest, Python 3.13) (push) Has been cancelled
CPU Test / Test (Others, latest, Python 3.13) (push) Has been cancelled
CPU Test / Test (Store, latest, Python 3.13) (push) Has been cancelled
CPU Test / Test (Utilities, latest, Python 3.13) (push) Has been cancelled
CPU Test / Test (JavaScript) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:44:17 +08:00

455 lines
16 KiB
Python

# Copyright (c) Microsoft. All rights reserved.
from __future__ import annotations
import json
import time
from contextlib import contextmanager
from dataclasses import dataclass
from types import TracebackType
from typing import Any, ContextManager, Dict, Iterator, List, Optional, Tuple, Type, cast
import opentelemetry.trace as trace_api
import pytest
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
from opentelemetry.trace import Status, StatusCode
import agentlightning.emitter.annotation as annotation_module
from agentlightning.emitter.annotation import OperationContext, emit_annotation, operation
from agentlightning.semconv import AGL_ANNOTATION, AGL_OPERATION, LightningSpanAttributes
from agentlightning.tracer.dummy import DummySpanRecordingContext, DummyTracer
from agentlightning.types import SpanCoreFields, TraceStatus
from agentlightning.types.tracer import Attributes
from agentlightning.utils.otel import (
extract_links_from_attributes,
filter_and_unflatten_attributes,
make_link_attributes,
query_linked_spans,
)
class RecordingTracer:
def __init__(self) -> None:
self._delegate = DummyTracer()
self.recordings: List[DummySpanRecordingContext] = []
def operation_context(
self,
name: str,
attributes: Optional[Attributes] = None,
start_time: Optional[float] = None,
end_time: Optional[float] = None,
) -> ContextManager[DummySpanRecordingContext]:
parent_ctx = self._delegate.operation_context(name, attributes, start_time, end_time)
@contextmanager
def _wrapper() -> Iterator[DummySpanRecordingContext]:
with parent_ctx as recording:
self.recordings.append(recording)
yield recording
return _wrapper()
def create_span(
self,
name: str,
attributes: Optional[Attributes] = None,
timestamp: Optional[float] = None,
status: Optional[TraceStatus] = None,
) -> SpanCoreFields:
return self._delegate.create_span(name, attributes, timestamp, status)
class OtelSpanRecordingContext:
def __init__(self, span: trace_api.Span) -> None:
self._span = span
def record_exception(self, exception: BaseException) -> None:
self._span.record_exception(exception)
self.record_status("ERROR", str(exception))
def record_attributes(self, attributes: Dict[str, Any]) -> None:
for key, value in attributes.items():
self._span.set_attribute(key, value)
def record_status(self, status_code: str, description: Optional[str] = None) -> None:
self._span.set_status(Status(StatusCode[status_code], description)) # type: ignore[index]
def get_recorded_span(self) -> None:
raise NotImplementedError()
class OtelTracerAdapter:
def __init__(self, tracer: trace_api.Tracer) -> None:
self._tracer = tracer
def operation_context(
self,
name: str,
attributes: Optional[Dict[str, Any]] = None,
start_time: Optional[float] = None,
end_time: Optional[float] = None,
):
ctx = self._tracer.start_as_current_span(name, attributes=attributes)
class _ContextManager:
def __enter__(self) -> OtelSpanRecordingContext:
span = ctx.__enter__()
return OtelSpanRecordingContext(span)
def __exit__(
self,
exc_type: Optional[Type[BaseException]],
exc_val: Optional[BaseException],
exc_tb: Optional[TracebackType],
) -> bool:
result = ctx.__exit__(exc_type, exc_val, exc_tb)
return bool(result)
return _ContextManager()
def create_span(
self,
name: str,
attributes: Optional[Dict[str, Any]] = None,
timestamp: Optional[float] = None,
status: Optional[TraceStatus] = None,
) -> SpanCoreFields:
span = self._tracer.start_span(name, attributes=attributes)
if status:
span.set_status(Status(StatusCode[status.status_code], status.description)) # type: ignore[index]
span.end()
start = timestamp or time.time()
return SpanCoreFields(
name=name,
attributes=attributes or {},
start_time=start,
end_time=start,
status=status or TraceStatus(status_code="OK"),
)
def _install_recording_tracer(monkeypatch: pytest.MonkeyPatch) -> RecordingTracer:
tracer = RecordingTracer()
def fake_get_active_tracer() -> RecordingTracer:
return tracer
monkeypatch.setattr(annotation_module, "get_active_tracer", fake_get_active_tracer)
return tracer
def _resolve_attr(recording: DummySpanRecordingContext, key: str) -> Any:
if key in recording.attributes:
value = recording.attributes[key]
else:
value = filter_and_unflatten_attributes(recording.attributes, key)
if isinstance(value, str):
try:
return json.loads(value)
except json.JSONDecodeError:
return value
return value
@dataclass
class ComplexResult:
values: Tuple[int, ...]
marker: str
def test_operation_context_serializes_inputs(monkeypatch: pytest.MonkeyPatch) -> None:
tracer = _install_recording_tracer(monkeypatch)
ctx = OperationContext("custom-span", {"meta": {"foo": 1}, "count": 2})
with ctx as op:
op.set_input({"payload": 1}, flag=True)
op.set_output({"success": True})
recording = tracer.recordings[-1]
assert recording.name == "custom-span"
assert recording.attributes["meta.foo"] == 1
assert recording.attributes["count"] == 2
input_prefix = LightningSpanAttributes.OPERATION_INPUT.value
assert _resolve_attr(recording, f"{input_prefix}.args") == [{"payload": 1}]
assert recording.attributes[f"{input_prefix}.flag"] is True
assert _resolve_attr(recording, LightningSpanAttributes.OPERATION_OUTPUT.value) == {"success": True}
def test_operation_context_set_input_supports_multiple_values(monkeypatch: pytest.MonkeyPatch) -> None:
tracer = _install_recording_tracer(monkeypatch)
ctx = OperationContext("ctx", {})
with ctx as op:
op.set_input(1, 2, data={"foo": ["bar"]}, flags=[True, False])
recording = tracer.recordings[-1]
input_prefix = LightningSpanAttributes.OPERATION_INPUT.value
assert _resolve_attr(recording, f"{input_prefix}.args") == [1, 2]
assert _resolve_attr(recording, f"{input_prefix}.data") == {"foo": ["bar"]}
assert _resolve_attr(recording, f"{input_prefix}.flags") == [True, False]
def test_operation_context_set_input_expands_positional_attributes(monkeypatch: pytest.MonkeyPatch) -> None:
tracer = _install_recording_tracer(monkeypatch)
ctx = OperationContext("ctx", {})
with ctx as op:
op.set_input("alpha", "beta")
recording = tracer.recordings[-1]
input_prefix = LightningSpanAttributes.OPERATION_INPUT.value
assert recording.attributes[f"{input_prefix}.args.0"] == "alpha"
assert recording.attributes[f"{input_prefix}.args.1"] == "beta"
def test_operation_context_serializes_non_serializable_output(monkeypatch: pytest.MonkeyPatch) -> None:
tracer = _install_recording_tracer(monkeypatch)
class CustomObject:
def __str__(self) -> str:
return "custom-output"
ctx = OperationContext("ctx", {})
with ctx as op:
op.set_output(CustomObject())
recording = tracer.recordings[-1]
assert (
json.loads(cast(str, recording.attributes[LightningSpanAttributes.OPERATION_OUTPUT.value])) == "custom-output"
)
def test_operation_context_records_exceptions(monkeypatch: pytest.MonkeyPatch) -> None:
tracer = _install_recording_tracer(monkeypatch)
ctx = OperationContext("custom-span", {})
with pytest.raises(RuntimeError):
with ctx:
raise RuntimeError("boom")
recording = tracer.recordings[-1]
assert "exception.type" in recording.attributes
assert recording.status.status_code == "ERROR"
assert recording.status.description == "boom"
def test_operation_factory_context_records_inputs_and_outputs(monkeypatch: pytest.MonkeyPatch) -> None:
tracer = _install_recording_tracer(monkeypatch)
with operation(tags=["one", "two"]) as ctx:
ctx.set_input("alpha", meta={"score": 0.5})
ctx.set_output(["beta", "gamma"])
recording = tracer.recordings[-1]
input_prefix = LightningSpanAttributes.OPERATION_INPUT.value
assert recording.name == AGL_OPERATION
assert recording.attributes["tags"] == ["one", "two"]
assert _resolve_attr(recording, f"{input_prefix}.args") == ["alpha"]
assert _resolve_attr(recording, f"{input_prefix}.meta") == {"score": 0.5}
assert _resolve_attr(recording, LightningSpanAttributes.OPERATION_OUTPUT.value) == ["beta", "gamma"]
def test_operation_factory_aliases_name_attribute(monkeypatch: pytest.MonkeyPatch) -> None:
tracer = _install_recording_tracer(monkeypatch)
with operation(name="custom-operation") as ctx:
ctx.set_output("done")
recording = tracer.recordings[-1]
assert recording.attributes[LightningSpanAttributes.OPERATION_NAME.value] == "custom-operation"
def test_operation_factory_uses_standard_span_name(monkeypatch: pytest.MonkeyPatch) -> None:
tracer = _install_recording_tracer(monkeypatch)
with operation(user={"id": 5}) as ctx:
ctx.set_output("done")
recording = tracer.recordings[-1]
assert recording.name == AGL_OPERATION
assert recording.attributes["user.id"] == 5
def test_operation_rejects_custom_span_names() -> None:
with pytest.raises(ValueError):
operation("custom-name") # type: ignore
def test_operation_decorator_sync_records_span_attributes(monkeypatch: pytest.MonkeyPatch) -> None:
tracer = _install_recording_tracer(monkeypatch)
@operation(category={"kind": "combine"})
def combine(data: Dict[str, int], *, meta: Dict[str, str]) -> Dict[str, Any]:
return {"joined": {**data, **meta}}
result = combine({"value": 1}, meta={"source": "unit"})
assert result == {"joined": {"value": 1, "source": "unit"}}
recording = tracer.recordings[-1]
assert recording.name == AGL_OPERATION
assert recording.attributes["category.kind"] == "combine"
input_prefix = LightningSpanAttributes.OPERATION_INPUT.value
assert _resolve_attr(recording, f"{input_prefix}.data") == {"value": 1}
assert _resolve_attr(recording, f"{input_prefix}.meta") == {"source": "unit"}
assert recording.attributes[LightningSpanAttributes.OPERATION_NAME.value] == "combine"
assert _resolve_attr(recording, LightningSpanAttributes.OPERATION_OUTPUT.value) == result
def test_operation_decorator_aliases_operation_name_attribute(monkeypatch: pytest.MonkeyPatch) -> None:
tracer = _install_recording_tracer(monkeypatch)
@operation(name="explicit-name")
def compute(value: int) -> int:
return value * 2
assert compute(3) == 6
recording = tracer.recordings[-1]
assert recording.attributes[LightningSpanAttributes.OPERATION_NAME.value] == "explicit-name"
def test_operation_decorator_handles_complex_signature(monkeypatch: pytest.MonkeyPatch) -> None:
tracer = _install_recording_tracer(monkeypatch)
@operation()
def complicated(
first: int,
/,
required: str,
default: int = 5,
*extra: int,
kwonly: str,
kwdefault: str = "fallback",
**rest: Any,
) -> ComplexResult:
return ComplexResult(values=(first, len(extra), len(rest)), marker=kwonly + kwdefault + required)
result = complicated(1, "req", 7, 8, 9, kwonly="x", kwdefault="y", tag="value")
recording = tracer.recordings[-1]
input_prefix = LightningSpanAttributes.OPERATION_INPUT.value
assert _resolve_attr(recording, f"{input_prefix}.first") == 1
assert _resolve_attr(recording, f"{input_prefix}.required") == "req"
assert _resolve_attr(recording, f"{input_prefix}.default") == 7
assert _resolve_attr(recording, f"{input_prefix}.extra") == [8, 9]
assert _resolve_attr(recording, f"{input_prefix}.kwonly") == "x"
assert _resolve_attr(recording, f"{input_prefix}.kwdefault") == "y"
assert _resolve_attr(recording, f"{input_prefix}.rest") == {"tag": "value"}
assert recording.attributes[LightningSpanAttributes.OPERATION_NAME.value] == "complicated"
assert _resolve_attr(recording, LightningSpanAttributes.OPERATION_OUTPUT.value) == (
"ComplexResult(values=(1, 2, 1), marker='xyreq')"
)
assert isinstance(result, ComplexResult)
assert recording.status.status_code == "OK"
def test_operation_name_alias_does_not_override_explicit_attribute(monkeypatch: pytest.MonkeyPatch) -> None:
_install_recording_tracer(monkeypatch)
attrs = {
LightningSpanAttributes.OPERATION_NAME.value: "explicit-name",
}
with pytest.raises(ValueError, match="specify both"):
with operation(name="alias-name", **attrs) as ctx:
ctx.set_output("done")
def test_operation_decorator_records_exceptions(monkeypatch: pytest.MonkeyPatch) -> None:
tracer = _install_recording_tracer(monkeypatch)
@operation()
def fail(value: int) -> int:
raise ValueError("bad input")
with pytest.raises(ValueError):
fail(1)
recording = tracer.recordings[-1]
assert recording.status.status_code == "ERROR"
assert recording.status.description == "bad input"
@pytest.mark.asyncio()
async def test_operation_async_wrapper_records_attributes(monkeypatch: pytest.MonkeyPatch) -> None:
tracer = _install_recording_tracer(monkeypatch)
@operation()
async def echo(payload: Dict[str, Any]) -> Dict[str, Any]:
return {"payload": payload}
result = await echo({"value": 3})
assert result == {"payload": {"value": 3}}
recording = tracer.recordings[-1]
prefix = LightningSpanAttributes.OPERATION_INPUT.value
assert _resolve_attr(recording, f"{prefix}.payload") == {"value": 3}
assert _resolve_attr(recording, LightningSpanAttributes.OPERATION_OUTPUT.value) == result
def test_operation_span_can_be_resolved_via_annotation_links(monkeypatch: pytest.MonkeyPatch) -> None:
provider = TracerProvider()
exporter = InMemorySpanExporter()
provider.add_span_processor(SimpleSpanProcessor(exporter))
tracer = OtelTracerAdapter(provider.get_tracer(__name__))
monkeypatch.setattr(annotation_module, "get_active_tracer", lambda: tracer)
@operation(conversation_id="conv-1")
def decorated(value: int) -> int:
return value + 1
assert decorated(41) == 42
spans = exporter.get_finished_spans()
operation_span = next(span for span in spans if span.name == AGL_OPERATION)
assert operation_span.attributes["conversation_id"] == "conv-1" # type: ignore
trace_id_hex = trace_api.format_trace_id(operation_span.context.trace_id) # type: ignore
span_id_hex = trace_api.format_span_id(operation_span.context.span_id) # type: ignore
link_attrs = make_link_attributes({"trace_id": trace_id_hex, "span_id": span_id_hex})
emit_annotation({**link_attrs, "note": "operation-follow-up"})
spans = exporter.get_finished_spans()
annotation_span = next(span for span in spans if span.name == AGL_ANNOTATION)
annotation_links = extract_links_from_attributes(dict(annotation_span.attributes or {}))
matches = query_linked_spans([operation_span], annotation_links)
assert matches == [operation_span]
def test_operation_honors_propagate_flag(monkeypatch: pytest.MonkeyPatch) -> None:
def fail_get_active_tracer() -> RecordingTracer:
raise AssertionError("get_active_tracer should not be called when propagate=False")
monkeypatch.setattr(annotation_module, "get_active_tracer", fail_get_active_tracer)
@operation(propagate=False)
def decorated(value: int) -> int:
return value
assert decorated(7) == 7
with operation(propagate=False, value=7) as op:
with pytest.raises(RuntimeError):
op.span()
assert op.span() is not None
assert op.span().name == AGL_OPERATION
assert op.span().attributes == {"value": 7}
assert op.span().status.status_code == "OK"
assert op.span().status.description is None
assert op.span().start_time is not None
assert op.span().end_time is not None
assert op.span().start_time < op.span().end_time # type: ignore