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
455 lines
16 KiB
Python
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
|