# 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