chore: import upstream snapshot with attribution
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

This commit is contained in:
wehub-resource-sync
2026-07-13 12:44:17 +08:00
commit 85742ab165
588 changed files with 320176 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
# Copyright (c) Microsoft. All rights reserved.
+433
View File
@@ -0,0 +1,433 @@
# Copyright (c) Microsoft. All rights reserved.
# pyright: reportPrivateUsage=false
from __future__ import annotations
import logging
import sys
from typing import Any, Dict, List, Optional, Sequence, Tuple
import pytest
import agentlightning.utils.metrics as metrics_module
from agentlightning.utils.metrics import ConsoleMetricsBackend, MetricsBackend, MultiMetricsBackend
from ..common.prometheus_stub import make_prometheus_stub
pytestmark = pytest.mark.utils
def test_validate_labels_reports_missing_label_with_metric_name() -> None:
labels = {"method": "GET", "status": "200"}
result = metrics_module._validate_labels("counter", "requests", labels, ("method", "status"))
assert result == (("method", "GET"), ("status", "200"))
with pytest.raises(ValueError) as excinfo:
metrics_module._validate_labels("counter", "requests", {"method": "POST"}, ("method", "status"))
message = str(excinfo.value)
assert "Counter 'requests'" in message
assert "'status' is required" in message
def test_normalize_label_names_preserves_original_order() -> None:
assert metrics_module._normalize_label_names(["b", "a", "b"]) == ("b", "a", "b")
assert metrics_module._normalize_label_names(None) == ()
def test_console_backend_logs_counters_with_registration_order() -> None:
backend = ConsoleMetricsBackend(log_interval_seconds=0.0)
line = backend._log_counter(
"rate",
{"group1": "a", "group2": "b"},
timestamps=[0.0, 1.0],
amounts=[1.0, 1.0],
snapshot_time=5.0,
)
assert line == "rate{group1=a,group2=b}=0.40/s"
def test_console_backend_logs_histograms_with_human_units() -> None:
backend = ConsoleMetricsBackend()
line = backend._log_histogram(
"latency",
{"group1": "a", "group2": "b"},
values=[0.00395, 0.0168, 3.5],
buckets=(0.5,),
snapshot_time=1.0,
)
assert line is not None and line.startswith("latency{group1=a,group2=b}=")
payload = line.rsplit("=", 1)[1]
p50, p95, p99 = payload.split(",", 2)
assert p50.endswith("ms")
assert p95.endswith("s")
assert p99.endswith("s")
def test_console_backend_respects_group_level_limit():
backend = ConsoleMetricsBackend(group_level=2, log_interval_seconds=0.0)
truncated = backend._truncate_labels_for_logging({"group1": "a", "group2": "b", "group3": "c"}, backend.group_level)
line = backend._log_counter("metric", truncated, [0.0, 2.0], [1.0, 1.0], snapshot_time=5.0)
assert line == "metric{group1=a,group2=b}=0.40/s"
def test_console_backend_log_uses_logger(caplog: pytest.LogCaptureFixture) -> None:
backend = ConsoleMetricsBackend()
caplog.set_level(logging.INFO, logger="agentlightning.utils.metrics")
backend._log("hello metrics")
assert any(record.message == "hello metrics" for record in caplog.records)
class _RecordingBackend(MetricsBackend):
def __init__(self) -> None:
self.calls: List[Tuple[str, Tuple[Any, ...]]] = []
def register_counter(
self,
name: str,
label_names: Optional[Sequence[str]] = None,
group_level: Optional[int] = None,
) -> None:
self.calls.append(("register_counter", (name, tuple(label_names or ()), group_level)))
def register_histogram(
self,
name: str,
label_names: Optional[Sequence[str]] = None,
buckets: Optional[Sequence[float]] = None,
group_level: Optional[int] = None,
) -> None:
self.calls.append(("register_histogram", (name, tuple(label_names or ()), tuple(buckets or ()), group_level)))
async def inc_counter(self, name: str, amount: float = 1.0, labels: Optional[Dict[str, str]] = None) -> None:
self.calls.append(("inc_counter", (name, amount, labels or {})))
async def observe_histogram(self, name: str, value: float, labels: Optional[Dict[str, str]] = None) -> None:
self.calls.append(("observe_histogram", (name, value, labels or {})))
@pytest.mark.asyncio
async def test_multi_metrics_backend_fans_out_calls() -> None:
backend_a = _RecordingBackend()
backend_b = _RecordingBackend()
multi = MultiMetricsBackend([backend_a, backend_b])
multi.register_counter("hits", label_names=["method"])
multi.register_histogram("latency", label_names=["method"], buckets=[0.1, 1.0])
await multi.inc_counter("hits", amount=2.5, labels={"method": "GET"})
await multi.observe_histogram("latency", value=0.4, labels={"method": "GET"})
expected_calls: List[Tuple[str, Tuple[Any, ...]]] = [
("register_counter", ("hits", ("method",), None)),
("register_histogram", ("latency", ("method",), (0.1, 1.0), None)),
("inc_counter", ("hits", 2.5, {"method": "GET"})),
("observe_histogram", ("latency", 0.4, {"method": "GET"})),
]
assert backend_a.calls == expected_calls
assert backend_b.calls == expected_calls
@pytest.mark.asyncio
async def test_prometheus_backend_binds_stubbed_prometheus(monkeypatch: pytest.MonkeyPatch) -> None:
stub = make_prometheus_stub()
monkeypatch.setitem(sys.modules, "prometheus_client", stub)
backend = metrics_module.PrometheusMetricsBackend()
backend.register_counter("hits", ["method"])
backend.register_histogram("latency", ["method"], buckets=[0.5])
await backend.inc_counter("hits", amount=2.0, labels={"method": "GET"})
await backend.observe_histogram("latency", value=0.1, labels={"method": "GET"})
counter_instance = stub.counter_instances[0]
histogram_instance = stub.histogram_instances[0]
assert counter_instance.children[(("method", "GET"),)].value == 2.0
assert histogram_instance.children[(("method", "GET"),)].values == [0.1]
def test_prometheus_backend_normalizes_metric_names(monkeypatch: pytest.MonkeyPatch) -> None:
stub = make_prometheus_stub()
monkeypatch.setitem(sys.modules, "prometheus_client", stub)
backend = metrics_module.PrometheusMetricsBackend()
backend.register_counter("api.v1.hits")
backend.register_histogram("latency.v1")
assert stub.counter_instances[0].name == "api_v1_hits"
assert stub.histogram_instances[0].name == "latency_v1"
def test_prometheus_backend_detects_normalized_name_conflicts(monkeypatch: pytest.MonkeyPatch) -> None:
stub = make_prometheus_stub()
monkeypatch.setitem(sys.modules, "prometheus_client", stub)
backend = metrics_module.PrometheusMetricsBackend()
backend.register_counter("api.v1.hits")
with pytest.raises(ValueError):
backend.register_histogram("api_v1_hits")
def _split_segments(log_lines: Sequence[str]) -> List[str]:
segments: List[str] = []
for line in log_lines:
segments.extend(part.strip() for part in line.split(" "))
return [seg for seg in segments if seg]
@pytest.mark.asyncio
async def test_console_backend_sliding_window_rate_and_eviction(monkeypatch: pytest.MonkeyPatch) -> None:
backend = ConsoleMetricsBackend(window_seconds=5.0, log_interval_seconds=0.0, group_level=2)
backend.register_counter("requests", ["group", "path"])
logged: List[str] = []
backend._log = logged.append # type: ignore[assignment]
times = iter([0.0, 1.0, 6.0])
monkeypatch.setattr(metrics_module.time, "time", lambda: next(times))
labels = {"group": "api", "path": "/list"}
await backend.inc_counter("requests", labels=labels)
await backend.inc_counter("requests", labels=labels)
await backend.inc_counter("requests", labels=labels)
segments = [seg for seg in _split_segments(logged) if seg.startswith("requests{group=api,path=/list}")]
assert len(segments) == 3
latest = segments[-1]
rate_str = latest.rsplit("=", 1)[1].rstrip("/s")
rate = float(rate_str)
assert abs(rate - 0.40) < 1e-2
def _duration_to_seconds(payload: str) -> float:
if payload.endswith("ms"):
return float(payload[:-2]) / 1_000
if payload.endswith("µs"):
return float(payload[:-2]) / 1_000_000
if payload.endswith("ns"):
return float(payload[:-2]) / 1_000_000_000
if payload.endswith("s"):
return float(payload[:-1])
raise AssertionError(f"Unknown duration format: {payload}")
@pytest.mark.asyncio
async def test_console_backend_histogram_quantiles_and_group_depth(monkeypatch: pytest.MonkeyPatch) -> None:
backend = ConsoleMetricsBackend(window_seconds=10.0, log_interval_seconds=0.0, group_level=2)
backend.register_histogram("latency", ["service", "endpoint", "status"])
logged: List[str] = []
backend._log = logged.append # type: ignore[assignment]
times = iter([0.0, 1.0, 2.0, 3.0])
monkeypatch.setattr(metrics_module.time, "time", lambda: next(times))
svc_a_labels = {"service": "svcA", "endpoint": "/search", "status": "200"}
svc_b_labels = {"service": "svcB", "endpoint": "/chat", "status": "500"}
await backend.observe_histogram("latency", value=0.01, labels=svc_a_labels)
await backend.observe_histogram("latency", value=0.02, labels=svc_a_labels)
await backend.observe_histogram("latency", value=0.03, labels=svc_a_labels)
await backend.observe_histogram("latency", value=2.0, labels=svc_b_labels)
svc_a_segments = [
seg for seg in _split_segments(logged) if seg.startswith("latency{service=svcA,endpoint=/search}")
]
assert svc_a_segments, "expected log entries for service A"
latest = svc_a_segments[-1]
assert "status" not in latest
payload = latest.rsplit("=", 1)[1]
p50_str, p95_str, p99_str = payload.split(",", 2)
p50 = _duration_to_seconds(p50_str)
p95 = _duration_to_seconds(p95_str)
p99 = _duration_to_seconds(p99_str)
assert abs(p50 - 0.02) < 1e-3
assert abs(p95 - 0.029) < 5e-3
assert abs(p99 - 0.0298) < 5e-3
@pytest.mark.asyncio
async def test_console_backend_logs_all_metric_groups(monkeypatch: pytest.MonkeyPatch) -> None:
backend = ConsoleMetricsBackend(window_seconds=None, log_interval_seconds=0.0)
backend.register_counter("requests", ["group"])
backend.register_counter("errors", ["group"])
logged: List[str] = []
backend._log = logged.append # type: ignore[assignment]
times = iter([0.0, 1.0])
monkeypatch.setattr(metrics_module.time, "time", lambda: next(times))
await backend.inc_counter("requests", labels={"group": "api"})
await backend.inc_counter("errors", labels={"group": "api"})
assert logged, "expected log output"
last_line = logged[-1]
assert "requests=" in last_line
assert "errors=" in last_line
def test_console_backend_snapshot_logs_single_line() -> None:
backend = ConsoleMetricsBackend()
logged: List[str] = []
backend._log = logged.append # type: ignore[assignment]
backend._log_snapshot(
[
("counter", {"g": "1"}, [0.0, 1.0], [1.0, 1.0]),
],
[
("latency", {"g": "1"}, [0.1, 0.2], (0.5,)),
],
snapshot_time=1.5,
)
assert logged and "counter=" in logged[0] and "latency=" in logged[0]
def test_console_backend_snapshot_entries_are_sorted() -> None:
backend = ConsoleMetricsBackend(group_level=1)
logged: List[str] = []
backend._log = logged.append # type: ignore[assignment]
backend._log_snapshot(
[
("zeta", {"a": "1"}, [0.0], [1.0]),
("alpha", {"b": "2"}, [0.1], [2.0]),
],
[],
snapshot_time=1.0,
)
assert logged, "expected log output"
line = logged[-1]
assert line.startswith("alpha{b=2}")
assert " zeta{a=1}" in line
def test_console_backend_group_level_none_aggregates_all_label_groups() -> None:
backend = ConsoleMetricsBackend(group_level=None)
logged: List[str] = []
backend._log = logged.append # type: ignore[assignment]
backend._log_snapshot(
[
("requests", {"group": "api", "path": "/a"}, [0.0], [1.0]),
("requests", {"group": "api", "path": "/b"}, [0.5], [2.0]),
],
[
("latency", {"group": "api", "path": "/a"}, [0.1], (0.5,)),
("latency", {"group": "api", "path": "/b"}, [0.2], (0.5,)),
],
snapshot_time=1.0,
)
assert logged, "expected log output"
line = logged[-1]
assert line.count("requests=") == 1
assert line.count("latency=") == 1
def test_console_backend_group_level_positive_only_aggregates_prefix() -> None:
backend = ConsoleMetricsBackend(group_level=1)
logged: List[str] = []
backend._log = logged.append # type: ignore[assignment]
backend._log_snapshot(
[
("requests", {"a": "x", "b": "y"}, [0.0], [1.0]),
("requests", {"a": "x", "b": "z"}, [0.1], [2.0]),
("requests", {"a": "w", "b": "y"}, [0.2], [3.0]),
],
[],
snapshot_time=1.0,
)
assert logged, "expected log output"
line = logged[-1]
# With group_level=1 we should see aggregation by the first declared label key.
assert line.count("requests{a=w}") == 1
assert line.count("requests{a=x}") == 1
@pytest.mark.asyncio
async def test_console_backend_logs_preserve_registration_label_order(monkeypatch: pytest.MonkeyPatch) -> None:
backend = ConsoleMetricsBackend(window_seconds=None, log_interval_seconds=0.0, group_level=3)
backend.register_counter("requests", ["path", "method", "status"])
logged: List[str] = []
backend._log = logged.append # type: ignore[assignment]
times = iter([0.0, 1.0])
monkeypatch.setattr(metrics_module.time, "time", lambda: next(times))
await backend.inc_counter("requests", labels={"status": "200", "path": "/search", "method": "GET"})
assert logged, "expected log output"
last_line = logged[-1]
assert "requests{path=/search,method=GET,status=200}" in last_line
@pytest.mark.asyncio
async def test_console_backend_metric_specific_group_level_applies(monkeypatch: pytest.MonkeyPatch) -> None:
backend = ConsoleMetricsBackend(window_seconds=None, log_interval_seconds=0.0)
backend.register_counter("requests", ["service", "endpoint", "status"], group_level=2)
logged: List[str] = []
backend._log = logged.append # type: ignore[assignment]
times = iter([0.0, 1.0])
monkeypatch.setattr(metrics_module.time, "time", lambda: next(times))
await backend.inc_counter("requests", labels={"status": "200", "endpoint": "/search", "service": "svcA"})
assert logged, "expected log output"
last_line = logged[-1]
assert "requests{service=svcA,endpoint=/search}" in last_line
assert "status" not in last_line
@pytest.mark.asyncio
async def test_console_backend_global_group_level_overrides_metric(monkeypatch: pytest.MonkeyPatch) -> None:
backend = ConsoleMetricsBackend(window_seconds=None, log_interval_seconds=0.0, group_level=1)
backend.register_counter("requests", ["service", "endpoint"], group_level=2)
logged: List[str] = []
backend._log = logged.append # type: ignore[assignment]
times = iter([0.0, 1.0])
monkeypatch.setattr(metrics_module.time, "time", lambda: next(times))
await backend.inc_counter("requests", labels={"service": "svcA", "endpoint": "/search"})
assert logged, "expected log output"
last_line = logged[-1]
assert "requests{service=svcA}" in last_line
assert "endpoint" not in last_line
@pytest.mark.asyncio
async def test_console_backend_histogram_metric_group_level(monkeypatch: pytest.MonkeyPatch) -> None:
backend = ConsoleMetricsBackend(window_seconds=None, log_interval_seconds=0.0)
backend.register_histogram("latency", ["service", "endpoint"], group_level=1)
logged: List[str] = []
backend._log = logged.append # type: ignore[assignment]
times = iter([0.0, 1.0, 2.0])
monkeypatch.setattr(metrics_module.time, "time", lambda: next(times))
await backend.observe_histogram("latency", value=0.01, labels={"service": "svcA", "endpoint": "/search"})
await backend.observe_histogram("latency", value=0.02, labels={"service": "svcA", "endpoint": "/chat"})
assert logged, "expected log output"
latest = logged[-1]
assert "latency{service=svcA}" in latest
assert "endpoint" not in latest
+735
View File
@@ -0,0 +1,735 @@
# Copyright (c) Microsoft. All rights reserved.
from __future__ import annotations
import json
import logging
from types import SimpleNamespace
from typing import Any, Dict, List, cast
import opentelemetry.trace as trace_api
import pytest
from opentelemetry.sdk.trace import ReadableSpan, SynchronousMultiSpanProcessor
from opentelemetry.semconv.attributes import exception_attributes
from opentelemetry.trace import TraceFlags
from pydantic import ValidationError
from agentlightning.semconv import LightningSpanAttributes, LinkPydanticModel
from agentlightning.types.tracer import Span
from agentlightning.utils import otel
from agentlightning.utils.otel import (
check_attributes_sanity,
extract_links_from_attributes,
extract_tags_from_attributes,
filter_and_unflatten_attributes,
filter_attributes,
flatten_attributes,
format_exception_attributes,
full_qualified_name,
get_tracer,
get_tracer_provider,
make_link_attributes,
make_tag_attributes,
query_linked_spans,
sanitize_attribute_value,
sanitize_attributes,
sanitize_list_attribute_sanity,
unflatten_attributes,
)
pytestmark = pytest.mark.utils
def _span_context(trace_id_hex: str, span_id_hex: str) -> trace_api.SpanContext:
return trace_api.SpanContext(
trace_id=int(trace_id_hex, 16),
span_id=int(span_id_hex, 16),
is_remote=False,
trace_flags=TraceFlags(TraceFlags.SAMPLED),
trace_state=trace_api.TraceState(),
)
def test_flatten_simple_nested_dict_and_list() -> None:
data = {"a": {"b": 1, "c": [2, 3]}}
result = flatten_attributes(data)
assert result == {
"a.b": 1,
"a.c": [2, 3],
}
def test_flatten_simple_nested_dict_and_list_with_leaf_expansion() -> None:
data = {"a": {"b": 1, "c": [2, 3]}}
result = flatten_attributes(data, expand_leaf_lists=True)
assert result == {
"a.b": 1,
"a.c.0": 2,
"a.c.1": 3,
}
def test_full_qualified_name_handles_builtin_and_custom_classes() -> None:
class LocalClass:
pass
builtin_result = full_qualified_name(int)
local_result = full_qualified_name(LocalClass)
assert builtin_result == "int"
assert local_result.endswith("LocalClass")
assert local_result.startswith("tests.utils.test_otel")
def test_flatten_empty_dict() -> None:
data: Dict[str, Any] = {}
assert flatten_attributes(data) == {}
def test_flatten_empty_list() -> None:
data: List[Any] = []
# No elements -> no keys
assert flatten_attributes(data) == {}
def test_flatten_root_list_of_primitives() -> None:
data = [10, 20, 30]
result = flatten_attributes(data)
assert result == {
"0": 10,
"1": 20,
"2": 30,
}
def test_flatten_nested_lists_and_dicts() -> None:
data: Dict[str, Any] = {
"users": [
{"name": "Alice", "tags": ["admin", "staff"]},
{"name": "Bob", "tags": []},
]
}
result = flatten_attributes(data)
assert result == {
"users.0.name": "Alice",
"users.0.tags": ["admin", "staff"],
"users.1.name": "Bob",
# Empty leaf lists remain implicit
}
def test_flatten_leaf_lists_can_stay_compact() -> None:
data = {"tags": ["fast", "reliable"]}
result = flatten_attributes(data, expand_leaf_lists=False)
assert result == {"tags": ["fast", "reliable"]}
def test_flatten_non_leaf_lists_still_expand_when_leaf_expansion_disabled() -> None:
data = {"users": [{"name": "Alice"}, {"name": "Bob"}]}
result = flatten_attributes(data, expand_leaf_lists=False)
assert result == {
"users.0.name": "Alice",
"users.1.name": "Bob",
}
def test_flatten_leaf_lists_with_mixed_types_warns_and_expands(caplog: pytest.LogCaptureFixture) -> None:
data = {"values": [1, "two"]}
caplog.set_level(logging.WARNING, logger="agentlightning.utils.otel")
result = flatten_attributes(data, expand_leaf_lists=False)
assert result == {
"values.0": 1,
"values.1": "two",
}
assert "mixed primitive types" in caplog.text
def test_flatten_leaf_lists_with_mixed_numbers_warns_and_expands(caplog: pytest.LogCaptureFixture) -> None:
data = {"values": [1, 2.0, 3]}
caplog.set_level(logging.WARNING, logger="agentlightning.utils.otel")
result = flatten_attributes(data, expand_leaf_lists=False)
assert result == {
"values.0": 1,
"values.1": 2.0,
"values.2": 3,
}
assert "mixed primitive types" in caplog.text
def test_flatten_mixed_types_and_none() -> None:
data = {
"a": True,
"b": None,
"c": 3.14,
"d": "hello",
"e": {"f": False},
}
result = flatten_attributes(data)
assert result == {
"a": True,
"b": None,
"c": 3.14,
"d": "hello",
"e.f": False,
}
def test_flatten_non_string_key_raises_value_error() -> None:
data = {
"a": {
1: "bad", # non-string key inside nested dict
}
}
with pytest.raises(ValueError) as excinfo:
flatten_attributes(data)
msg = str(excinfo.value)
assert "Only string keys are supported in dictionaries" in msg
# Ensure the offending key is mentioned
assert "'1'" in msg
assert "type <class 'int'>" in msg
def test_flatten_root_primitive_is_allowed() -> None:
# Even though the type hint says Dict/List, function behavior supports primitives.
data = 42
result = flatten_attributes(data) # type: ignore[arg-type]
assert result == {"": 42}
def test_unflatten_simple_nested_dict() -> None:
flat = {
"a.b": 1,
"a.c": 2,
}
result = unflatten_attributes(flat)
assert result == {"a": {"b": 1, "c": 2}}
def test_unflatten_consecutive_numeric_keys_to_list() -> None:
flat = {
"a.0": "x",
"a.1": "y",
"a.2": "z",
}
result = unflatten_attributes(flat)
assert result == {
"a": ["x", "y", "z"],
}
def test_unflatten_non_consecutive_numeric_keys_stays_dict() -> None:
flat = {
"a.0": "first",
"a.2": "third",
}
result = unflatten_attributes(flat)
# Keys are numeric but not consecutive -> remains dict
assert result == {
"a": {
"0": "first",
"2": "third",
}
}
def test_unflatten_mixed_numeric_and_non_numeric_keys_stays_dict() -> None:
flat = {
"a.0": "zero",
"a.foo": "bar",
}
result = unflatten_attributes(flat)
assert result == {
"a": {
"0": "zero",
"foo": "bar",
}
}
def test_unflatten_root_list_from_numeric_keys() -> None:
flat = {
"0": "a",
"1": "b",
"2": "c",
}
result = unflatten_attributes(flat)
# Root dict with all numeric keys 0..n-1 becomes list
assert result == ["a", "b", "c"]
def test_unflatten_empty_flat_dict_returns_empty_dict() -> None:
flat: Dict[str, Any] = {}
result = unflatten_attributes(flat)
assert result == {}
def test_unflatten_nested_lists_and_dicts() -> None:
flat = {
"users.0.name": "Alice",
"users.0.tags.0": "admin",
"users.0.tags.1": "staff",
"users.1.name": "Bob",
"users.1.tags.0": "guest",
}
result = unflatten_attributes(flat)
assert result == {
"users": [
{"name": "Alice", "tags": ["admin", "staff"]},
{"name": "Bob", "tags": ["guest"]},
]
}
def test_unflatten_list_of_lists() -> None:
flat = {
"a.0.0": 1,
"a.0.1": 2,
"a.1.0": 3,
}
result = unflatten_attributes(flat)
assert result == {
"a": [
[1, 2],
[3],
]
}
def test_unflatten_conflicting_primitive_and_nested_path_prefers_nested() -> None:
# "a" is first set to a primitive, then to a nested dict via "a.b"
flat = {
"a": 1,
"a.b": 2,
}
result = unflatten_attributes(flat)
# Primitive is overwritten by nested dict structure
assert result == {"a": {"b": 2}}
@pytest.mark.parametrize(
"value",
[
{"a": {"b": 1, "c": [2, 3]}},
{"x": [1, 2, {"y": 3}]},
{"root": [{"k": "v"}, {"k": "w"}]},
[{"name": "Alice"}, {"name": "Bob", "scores": [10, 20]}],
],
)
def test_round_trip_flatten_then_unflatten_preserves_structure(value: Dict[str, Any] | List[Any]) -> None:
flat = flatten_attributes(value) # type: ignore[arg-type]
reconstructed = unflatten_attributes(flat)
assert reconstructed == value
@pytest.mark.parametrize(
"flat",
[
{"a.b": 1, "a.c": 2},
{"0": "x", "1": "y"},
{
"users.0.name": "Alice",
"users.1.name": "Bob",
},
],
)
def test_round_trip_unflatten_then_flatten_preserves_flat_structure(flat: Dict[str, Any]) -> None:
nested = unflatten_attributes(flat)
re_flat = flatten_attributes(nested)
# Order of items in dict shouldn't matter
assert re_flat == flat
def test_round_trip_with_empty_list_information_loss_is_expected() -> None:
"""This documents the corner case: empty list flattens to {},
which unflattens back to {} (empty dict), losing the distinction.
"""
data: List[Any] = []
flat = flatten_attributes(data)
assert flat == {}
reconstructed = unflatten_attributes(flat)
assert reconstructed == {}
assert reconstructed != data # explicit documentation of the behavior
def test_sanitize_attribute_value_handles_primitives_and_lists() -> None:
assert sanitize_attribute_value("text") == "text"
assert sanitize_attribute_value(42) == 42
assert sanitize_attribute_value(3.14) == 3.14
assert sanitize_attribute_value(True) is True
assert sanitize_attribute_value([True, 2]) == [1, 2]
def test_sanitize_attribute_value_falls_back_to_json_for_hybrid_lists(caplog: pytest.LogCaptureFixture) -> None:
caplog.set_level(logging.WARNING, logger="agentlightning.utils.otel")
result = sanitize_attribute_value([1, "two"])
assert result == json.dumps([1, "two"])
assert "Failed to sanitize list attribute" in caplog.text
def test_sanitize_attribute_value_serializes_non_serializable_objects_when_forced() -> None:
class Unserializable:
def __str__(self) -> str:
return "forced-serialization"
result = sanitize_attribute_value(Unserializable())
assert json.loads(cast(str, result)) == "forced-serialization"
def test_sanitize_attribute_value_respects_force_flag() -> None:
class Unserializable:
pass
with pytest.raises(ValueError, match="Object must be JSON serializable"):
sanitize_attribute_value(Unserializable(), force=False)
def test_sanitize_attributes_returns_clean_values() -> None:
attributes = {
"name": "agent",
"enabled": True,
"scores": [1, True],
"flags": [True, False],
}
result = sanitize_attributes(attributes)
assert result["name"] == "agent"
assert result["enabled"] is True
assert result["scores"] == [1, 1]
assert result["flags"] == [True, False]
def test_sanitize_attributes_serializes_non_serializable_values_by_default() -> None:
class CustomValue:
def __str__(self) -> str:
return "custom-payload"
attributes = {"payload": CustomValue()}
result = sanitize_attributes(attributes)
assert json.loads(cast(str, result["payload"])) == "custom-payload"
def test_sanitize_attributes_respects_force_flag() -> None:
class CustomValue:
pass
attributes = {"payload": CustomValue()}
with pytest.raises(ValueError, match="Failed to sanitize attribute 'payload'"):
sanitize_attributes(attributes, force=False)
def test_sanitize_attributes_exposes_key_in_error() -> None:
class Bad:
pass
attributes = {"ok": "value", "bad": Bad()}
with pytest.raises(ValueError, match="Failed to sanitize attribute 'bad'"):
sanitize_attributes(attributes, force=False)
def test_format_exception_attributes_captures_metadata() -> None:
try:
raise RuntimeError("boom")
except RuntimeError as err:
attrs = format_exception_attributes(err)
assert attrs[exception_attributes.EXCEPTION_TYPE] == "RuntimeError"
assert attrs[exception_attributes.EXCEPTION_MESSAGE] == "boom"
assert attrs[exception_attributes.EXCEPTION_ESCAPED] is True
assert exception_attributes.EXCEPTION_STACKTRACE in attrs
assert "RuntimeError: boom" in attrs[exception_attributes.EXCEPTION_STACKTRACE] # type: ignore
def test_sanitize_list_attribute_sanity_supports_primitive_lists() -> None:
assert sanitize_list_attribute_sanity(["a", "b"]) == ["a", "b"]
assert sanitize_list_attribute_sanity([True, False]) == [True, False]
assert sanitize_list_attribute_sanity([1, False]) == [1, 0]
assert sanitize_list_attribute_sanity([1.0, 2, True]) == [1.0, 2.0, 1.0]
def test_sanitize_list_attribute_sanity_rejects_mixed_types() -> None:
with pytest.raises(ValueError, match="List must contain only one type of primitive values"):
sanitize_list_attribute_sanity([1, "two"])
def test_check_attributes_sanity_accepts_valid_payload() -> None:
attributes: Dict[str, Any] = {
"name": "agent",
"count": 3,
"ratio": 0.5,
"enabled": False,
"flags": [True, False],
"scores": [1, True],
}
check_attributes_sanity(attributes)
def test_check_attributes_sanity_requires_string_keys() -> None:
with pytest.raises(ValueError, match="Attribute key must be a string"):
check_attributes_sanity({1: "value"}) # type: ignore[arg-type]
def test_check_attributes_sanity_wraps_list_errors() -> None:
with pytest.raises(ValueError, match="Failed to sanitize list attribute 'mixed'"):
check_attributes_sanity({"mixed": [1, "two"]})
def test_check_attributes_sanity_rejects_non_primitive_values() -> None:
with pytest.raises(ValueError, match="Attribute value must be a string"):
check_attributes_sanity({"bad": {"nested": "value"}})
def test_make_and_extract_link_attributes_round_trip() -> None:
flattened = make_link_attributes(
{
"gen_ai.response.id": "response-123",
"span_id": "abcd1234abcd1234",
}
)
assert flattened == {
f"{LightningSpanAttributes.LINK.value}.0.key_match": "gen_ai.response.id",
f"{LightningSpanAttributes.LINK.value}.0.value_match": "response-123",
f"{LightningSpanAttributes.LINK.value}.1.key_match": "span_id",
f"{LightningSpanAttributes.LINK.value}.1.value_match": "abcd1234abcd1234",
}
extracted = extract_links_from_attributes(flattened)
assert [link.model_dump() for link in extracted] == [
{"key_match": "gen_ai.response.id", "value_match": "response-123"},
{"key_match": "span_id", "value_match": "abcd1234abcd1234"},
]
def test_make_link_attributes_rejects_non_string_values() -> None:
with pytest.raises(ValueError) as excinfo:
make_link_attributes({"span_id": 123}) # type: ignore
assert "Link value must be a string" in str(excinfo.value)
def test_make_tag_attributes_and_extract_round_trip() -> None:
flattened = make_tag_attributes(["fast", "reliable"])
assert flattened == {
f"{LightningSpanAttributes.TAG.value}.0": "fast",
f"{LightningSpanAttributes.TAG.value}.1": "reliable",
}
assert extract_tags_from_attributes(flattened) == ["fast", "reliable"]
def test_extract_tags_from_attributes_rejects_non_strings() -> None:
attributes = {
f"{LightningSpanAttributes.TAG.value}.0": 1,
}
with pytest.raises(ValidationError):
extract_tags_from_attributes(attributes)
def test_filter_attributes_keeps_exact_matches_and_children() -> None:
attributes = {
"agentlightning.link": "root",
"agentlightning.link.0.key_match": "trace_id",
"agentlightning.other": "discard",
"agentlightning.link_extra": "different_prefix",
}
filtered = filter_attributes(attributes, LightningSpanAttributes.LINK.value)
assert filtered == {
"agentlightning.link": "root",
"agentlightning.link.0.key_match": "trace_id",
}
def test_filter_and_unflatten_attributes_strips_prefix_and_rebuilds_nested_structure() -> None:
attributes = {
f"{LightningSpanAttributes.LINK.value}.0.key_match": "trace_id",
f"{LightningSpanAttributes.LINK.value}.0.value_match": "aaa",
f"{LightningSpanAttributes.LINK.value}.1.key_match": "span_id",
f"{LightningSpanAttributes.LINK.value}.1.value_match": "bbb",
}
result = filter_and_unflatten_attributes(attributes, LightningSpanAttributes.LINK.value)
assert result == [
{"key_match": "trace_id", "value_match": "aaa"},
{"key_match": "span_id", "value_match": "bbb"},
]
def test_filter_and_unflatten_attributes_rejects_exact_prefix_key() -> None:
attributes = {LightningSpanAttributes.LINK.value: "invalid"}
with pytest.raises(ValueError):
filter_and_unflatten_attributes(attributes, LightningSpanAttributes.LINK.value)
def test_query_linked_spans_matches_trace_id_on_readable_span() -> None:
readable_span = ReadableSpan(
name="upstream",
context=_span_context("a" * 32, "b" * 16),
attributes={},
)
assert readable_span.context is not None
links = [
LinkPydanticModel(key_match="trace_id", value_match=trace_api.format_trace_id(readable_span.context.trace_id)),
]
matches = query_linked_spans([readable_span], links)
assert matches == [readable_span]
def test_query_linked_spans_matches_custom_span_attributes() -> None:
custom_span = Span.from_attributes(
attributes={"gen_ai.response.id": "response-123", "custom": "needle"},
trace_id="c" * 32,
span_id="d" * 16,
)
links = [
LinkPydanticModel(key_match="gen_ai.response.id", value_match="response-123"),
LinkPydanticModel(key_match="custom", value_match="needle"),
]
matches = query_linked_spans([custom_span], links)
assert matches == [custom_span]
def test_query_linked_spans_excludes_span_with_mismatched_span_id() -> None:
span = Span.from_attributes(
attributes={"marker": "x"},
trace_id="e" * 32,
span_id="f" * 16,
)
links = [
LinkPydanticModel(key_match="span_id", value_match="deadbeefdeadbeef"),
LinkPydanticModel(key_match="marker", value_match="x"),
]
assert query_linked_spans([span], links) == []
def test_query_linked_spans_requires_all_links_to_match() -> None:
span = Span.from_attributes(
attributes={"marker": "x", "other": "y"},
trace_id="1" * 32,
span_id="2" * 16,
)
links = [
LinkPydanticModel(key_match="marker", value_match="x"),
LinkPydanticModel(key_match="other", value_match="z"),
]
assert query_linked_spans([span], links) == []
def test_query_linked_spans_handles_readable_span_without_context() -> None:
readable_span = ReadableSpan(name="orphan", context=None, attributes={"marker": "x"})
links = [LinkPydanticModel(key_match="marker", value_match="x")]
matches = query_linked_spans([readable_span], links)
assert matches == [readable_span]
def test_get_tracer_provider_raises_when_tracer_uninitialized(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(trace_api, "_TRACER_PROVIDER", None, raising=False)
with pytest.raises(RuntimeError):
get_tracer_provider(inspect=False)
def test_get_tracer_provider_logs_when_provider_not_sdk(
caplog: pytest.LogCaptureFixture, monkeypatch: pytest.MonkeyPatch
) -> None:
sentinel_provider = object()
monkeypatch.setattr(trace_api, "_TRACER_PROVIDER", sentinel_provider, raising=False)
monkeypatch.setattr(otel, "otel_get_tracer_provider", lambda: sentinel_provider)
caplog.set_level(logging.ERROR, logger=otel.logger.name)
returned = get_tracer_provider(inspect=False)
assert returned is sentinel_provider
assert any("Tracer provider is expected" in rec.getMessage() for rec in caplog.records)
def test_get_tracer_delegates_to_active_span_processor(monkeypatch: pytest.MonkeyPatch) -> None:
class DummyProvider:
def __init__(self) -> None:
self.calls: List[str] = []
def get_tracer(self, name: str) -> str:
self.calls.append(name)
return f"tracer:{name}"
provider = DummyProvider()
monkeypatch.setattr(trace_api, "_TRACER_PROVIDER", object(), raising=False)
monkeypatch.setattr(otel, "get_tracer_provider", lambda inspect=True: provider)
tracer = get_tracer()
assert tracer == "tracer:agentlightning"
assert provider.calls == ["agentlightning"]
def test_get_tracer_without_active_span_processor_builds_isolated_tracer(monkeypatch: pytest.MonkeyPatch) -> None:
provider = SimpleNamespace(
sampler="sampler",
resource="resource",
id_generator="id_gen",
)
created: Dict[str, Any] = {}
class DummyTracer:
def __init__(
self,
sampler: Any,
resource: Any,
span_processor: SynchronousMultiSpanProcessor,
id_generator: Any,
instrumentation_info: Any,
span_limits: Any,
instrumentation_scope: Any,
) -> None:
created["args"] = (
sampler,
resource,
span_processor,
id_generator,
instrumentation_info,
span_limits,
instrumentation_scope,
)
monkeypatch.setattr(trace_api, "_TRACER_PROVIDER", object(), raising=False)
monkeypatch.setattr(otel, "get_tracer_provider", lambda inspect=True: provider)
monkeypatch.setattr(otel, "Tracer", DummyTracer)
tracer = get_tracer(use_active_span_processor=False)
assert isinstance(created.get("args"), tuple)
assert created["args"][0] == "sampler"
assert created["args"][1] == "resource"
assert isinstance(created["args"][2], SynchronousMultiSpanProcessor)
assert created["args"][3] == "id_gen"
assert created["args"][4].name == "agentlightning"
assert tracer is not None
def test_get_tracer_raises_when_provider_not_initialized(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(trace_api, "_TRACER_PROVIDER", None, raising=False)
with pytest.raises(RuntimeError):
get_tracer()
+453
View File
@@ -0,0 +1,453 @@
# Copyright (c) Microsoft. All rights reserved.
# pyright: reportPrivateUsage=false
from __future__ import annotations
import gzip
from types import SimpleNamespace
from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple, cast
import pytest
from fastapi import Request
from opentelemetry.proto.collector.trace.v1.trace_service_pb2 import (
ExportTraceServiceRequest,
ExportTraceServiceResponse,
)
from opentelemetry.proto.common.v1.common_pb2 import AnyValue, KeyValue
from opentelemetry.proto.resource.v1.resource_pb2 import Resource as ProtoResource
from opentelemetry.proto.trace.v1.trace_pb2 import Span as ProtoSpan
from opentelemetry.proto.trace.v1.trace_pb2 import Status as ProtoStatus
from opentelemetry.sdk.resources import Resource
from opentelemetry.sdk.trace import ReadableSpan
from opentelemetry.sdk.trace.export import SpanExportResult
from starlette.types import Message, Scope
from agentlightning.semconv import LightningResourceAttributes
from agentlightning.utils import otlp
BASE_TIME_NANOS = 1_700_000_000_000_000_000
EVENT_TIME_OFFSET = 3_000_000_000
EVENT_TIME_SECONDS = (BASE_TIME_NANOS + EVENT_TIME_OFFSET) / 1_000_000_000
EXTRA_EVENT_TIME_OFFSET = 4_000_000_000
EXTRA_EVENT_TIME_SECONDS = (BASE_TIME_NANOS + EXTRA_EVENT_TIME_OFFSET) / 1_000_000_000
pytestmark = pytest.mark.utils
class _StubStore:
def __init__(self) -> None:
self.sequence_calls: List[tuple[str, str]] = []
self.next_value = 1
async def get_many_span_sequence_ids(self, rollout_attempt_ids: Sequence[Tuple[str, str]]) -> Sequence[int]:
self.sequence_calls.extend(rollout_attempt_ids)
allocations: List[int] = []
for _ in rollout_attempt_ids:
allocations.append(self.next_value)
self.next_value += 1
return allocations
def _make_request(
body: bytes,
*,
content_type: str = otlp.PROTOBUF_CT,
content_encoding: Optional[str] = None,
accept_encoding: Optional[str] = None,
) -> Request:
headers: List[tuple[bytes, bytes]] = [(b"content-type", content_type.encode())]
if content_encoding:
headers.append((b"content-encoding", content_encoding.encode()))
if accept_encoding:
headers.append((b"accept-encoding", accept_encoding.encode()))
scope: Scope = {
"type": "http",
"asgi": {"version": "3.0", "spec_version": "2.3"},
"method": "POST",
"path": "/v1/test",
"headers": headers,
}
body_sent = False
async def receive() -> Message:
nonlocal body_sent
if body_sent:
return {"type": "http.request", "body": b"", "more_body": False}
body_sent = True
return {"type": "http.request", "body": body, "more_body": False}
return Request(scope, receive)
def _set_any_value(av: AnyValue, value: object) -> None:
if isinstance(value, bool):
av.bool_value = value
elif isinstance(value, int):
av.int_value = value
elif isinstance(value, float):
av.double_value = value
elif isinstance(value, bytes):
av.bytes_value = value
elif isinstance(value, list):
for item in cast(List[Any], value):
_set_any_value(av.array_value.values.add(), item)
elif isinstance(value, dict):
for key, item in cast(Dict[str, Any], value).items():
kv = av.kvlist_value.values.add()
kv.key = key
_set_any_value(kv.value, item)
else:
av.string_value = str(value)
def _add_attribute(attrs: Iterable[KeyValue], key: str, value: object) -> None:
kv = attrs.add() # type: ignore
kv.key = key
_set_any_value(kv.value, value) # type: ignore
def _build_span_request() -> ExportTraceServiceRequest:
request = ExportTraceServiceRequest()
resource_spans = request.resource_spans.add()
_add_attribute(resource_spans.resource.attributes, LightningResourceAttributes.ROLLOUT_ID.value, "resource-rollout")
_add_attribute(resource_spans.resource.attributes, LightningResourceAttributes.ATTEMPT_ID.value, "resource-attempt")
_add_attribute(
resource_spans.resource.attributes,
LightningResourceAttributes.SPAN_SEQUENCE_ID.value,
"5",
)
resource_spans.schema_url = "https://example/schema"
scope_spans = resource_spans.scope_spans.add()
span = scope_spans.spans.add()
span.trace_id = bytes.fromhex("01" * 16)
span.span_id = bytes.fromhex("02" * 8)
span.parent_span_id = bytes.fromhex("03" * 8)
span.name = "test-span"
span.start_time_unix_nano = BASE_TIME_NANOS
span.end_time_unix_nano = BASE_TIME_NANOS + 2_000_000_000
span.status.code = ProtoStatus.STATUS_CODE_ERROR
span.status.message = "boom"
_add_attribute(span.attributes, "foo", "bar")
_add_attribute(span.attributes, LightningResourceAttributes.ROLLOUT_ID.value, "span-rollout")
_add_attribute(span.attributes, LightningResourceAttributes.ATTEMPT_ID.value, "span-attempt")
_add_attribute(span.attributes, LightningResourceAttributes.SPAN_SEQUENCE_ID.value, "7")
event = span.events.add()
event.name = "event"
event.time_unix_nano = BASE_TIME_NANOS + EVENT_TIME_OFFSET
_add_attribute(event.attributes, "event-attr", 9)
link = span.links.add()
link.trace_id = bytes.fromhex("04" * 16)
link.span_id = bytes.fromhex("05" * 8)
_add_attribute(link.attributes, "link-attr", True)
return request
@pytest.mark.asyncio
async def test_handle_otlp_export_success_with_gzip_response() -> None:
request_msg = _build_span_request()
body = request_msg.SerializeToString()
request = _make_request(
body,
accept_encoding="gzip;q=0.9,br",
)
received: List[ExportTraceServiceRequest] = []
async def callback(message: ExportTraceServiceRequest) -> None:
received.append(message)
response = await otlp.handle_otlp_export(
request,
ExportTraceServiceRequest,
ExportTraceServiceResponse,
callback,
signal_name="traces",
)
assert response.status_code == 200
assert received and received[0].SerializeToString() == body
assert response.headers["Content-Encoding"] == "gzip"
assert gzip.decompress(response.body) == ExportTraceServiceResponse().SerializeToString()
@pytest.mark.asyncio
async def test_handle_otlp_export_rejects_invalid_content_type() -> None:
request = _make_request(b"{}", content_type="application/json")
response = await otlp.handle_otlp_export(
request,
ExportTraceServiceRequest,
ExportTraceServiceResponse,
None,
signal_name="traces",
)
assert response.status_code == 400
status = otlp.Status() # type: ignore[attr-defined]
status.ParseFromString(response.body) # type: ignore
assert "Unsupported Content-Type" in status.message
@pytest.mark.asyncio
async def test_handle_otlp_export_rejects_bad_payload() -> None:
request = _make_request(b"not-a-proto")
response = await otlp.handle_otlp_export(
request,
ExportTraceServiceRequest,
ExportTraceServiceResponse,
None,
signal_name="traces",
)
assert response.status_code == 400
status = otlp.Status() # type: ignore[attr-defined]
status.ParseFromString(response.body) # type: ignore
assert "Unable to parse" in status.message
@pytest.mark.asyncio
async def test_handle_otlp_export_accepts_gzip_body() -> None:
request_msg = ExportTraceServiceRequest()
request_msg.resource_spans.add()
gz_body = gzip.compress(request_msg.SerializeToString())
request = _make_request(gz_body, content_encoding="gzip")
response = await otlp.handle_otlp_export(
request,
ExportTraceServiceRequest,
ExportTraceServiceResponse,
None,
signal_name="traces",
)
assert response.status_code == 200
@pytest.mark.asyncio
async def test_spans_from_proto_prefers_span_level_metadata() -> None:
store = _StubStore()
request = _build_span_request()
spans = await otlp.spans_from_proto(request, store.get_many_span_sequence_ids)
assert len(spans) == 1
span = spans[0]
assert span.rollout_id == "span-rollout"
assert span.attempt_id == "span-attempt"
assert span.sequence_id == 7
assert span.status.status_code == "ERROR"
assert span.events[0].timestamp == pytest.approx(EVENT_TIME_SECONDS) # type: ignore
assert span.links[0].context.trace_id == "0404" * 8
assert span.links[0].attributes == {"link-attr": True}
assert span.resource.attributes[LightningResourceAttributes.ROLLOUT_ID.value] == "resource-rollout"
assert span.resource.schema_url == "https://example/schema"
assert not store.sequence_calls
@pytest.mark.asyncio
async def test_spans_from_proto_requests_sequence_ids_when_missing() -> None:
store = _StubStore()
request = ExportTraceServiceRequest()
resource_spans = request.resource_spans.add()
_add_attribute(resource_spans.resource.attributes, LightningResourceAttributes.ROLLOUT_ID.value, "r1")
_add_attribute(resource_spans.resource.attributes, LightningResourceAttributes.ATTEMPT_ID.value, "a1")
scope_span = resource_spans.scope_spans.add()
span = scope_span.spans.add()
span.trace_id = b"" # exercise default ids
span.span_id = b""
span.name = "needs-seq"
spans = await otlp.spans_from_proto(request, store.get_many_span_sequence_ids)
assert len(spans) == 1
assert spans[0].sequence_id == 1
assert store.sequence_calls == [("r1", "a1")]
@pytest.mark.asyncio
async def test_spans_from_proto_bulk_issues_for_mixed_rollouts() -> None:
store = _StubStore()
request = ExportTraceServiceRequest()
resource_first = request.resource_spans.add()
_add_attribute(resource_first.resource.attributes, LightningResourceAttributes.ROLLOUT_ID.value, "r1")
_add_attribute(resource_first.resource.attributes, LightningResourceAttributes.ATTEMPT_ID.value, "a-default")
scope_first = resource_first.scope_spans.add()
span_missing = scope_first.spans.add()
span_missing.trace_id = bytes.fromhex("11" * 16)
span_missing.span_id = bytes.fromhex("22" * 8)
span_missing.name = "missing-seq"
_add_attribute(span_missing.attributes, LightningResourceAttributes.ATTEMPT_ID.value, "a1")
span_negative = scope_first.spans.add()
span_negative.trace_id = bytes.fromhex("33" * 16)
span_negative.span_id = bytes.fromhex("44" * 8)
span_negative.name = "negative-seq"
_add_attribute(span_negative.attributes, LightningResourceAttributes.ATTEMPT_ID.value, "a2")
_add_attribute(span_negative.attributes, LightningResourceAttributes.SPAN_SEQUENCE_ID.value, "-5")
resource_second = request.resource_spans.add()
_add_attribute(resource_second.resource.attributes, LightningResourceAttributes.ROLLOUT_ID.value, "r2")
_add_attribute(resource_second.resource.attributes, LightningResourceAttributes.ATTEMPT_ID.value, "b1")
scope_second = resource_second.scope_spans.add()
span_second = scope_second.spans.add()
span_second.trace_id = bytes.fromhex("55" * 16)
span_second.span_id = bytes.fromhex("66" * 8)
span_second.name = "other-rollout"
spans = await otlp.spans_from_proto(request, store.get_many_span_sequence_ids)
assert [span.name for span in spans] == ["missing-seq", "negative-seq", "other-rollout"]
assert [span.sequence_id for span in spans] == [1, 2, 3]
assert store.sequence_calls == [("r1", "a1"), ("r1", "a2"), ("r2", "b1")]
@pytest.mark.asyncio
async def test_spans_from_proto_skips_spans_without_ids() -> None:
store = _StubStore()
request = ExportTraceServiceRequest()
request.resource_spans.add() # missing rollout and attempt
spans = await otlp.spans_from_proto(request, store.get_many_span_sequence_ids)
assert spans == []
assert store.sequence_calls == []
def test_normalize_sequence_id_handles_bad_values(caplog: pytest.LogCaptureFixture) -> None:
caplog.set_level("WARNING")
assert otlp._normalize_sequence_id("not-int") is None
assert any("Invalid sequence_id" in record.message for record in caplog.records)
def test_any_value_to_python_full_roundtrip() -> None:
av = AnyValue()
_set_any_value(
av,
{
"s": "hello",
"b": True,
"i": 5,
"d": 1.5,
"arr": ["x", 2],
"nested": {"k": b"\x01"},
},
)
result = otlp._any_value_to_python(av)
assert result == {
"s": "hello",
"b": True,
"i": 5,
"d": 1.5,
"arr": ["x", 2],
"nested": {"k": "01"},
}
def test_kv_list_to_dict_converts_values() -> None:
resource = ProtoResource()
_add_attribute(resource.attributes, "num", 10)
_add_attribute(resource.attributes, "flag", False)
converted = otlp._kv_list_to_dict(resource.attributes)
assert converted == {"num": 10, "flag": False}
def test_bytes_to_hex_helpers() -> None:
assert otlp._bytes_to_trace_id_hex(b"") == "0" * 32
assert otlp._bytes_to_span_id_hex(b"") == "0" * 16
assert otlp._bytes_to_trace_id_hex(b"\xff") == "ff".rjust(32, "0")
assert otlp._bytes_to_span_id_hex(b"\xaa") == "aa".rjust(16, "0")
def test_events_and_links_from_proto() -> None:
span = ProtoSpan()
event = span.events.add()
event.name = "evt"
event.time_unix_nano = BASE_TIME_NANOS + EXTRA_EVENT_TIME_OFFSET
_add_attribute(event.attributes, "alpha", "beta")
link = span.links.add()
link.trace_id = bytes.fromhex("06" * 16)
link.span_id = bytes.fromhex("07" * 8)
_add_attribute(link.attributes, "delta", 1)
events = otlp._events_from_proto(span)
links = otlp._links_from_proto(span)
assert events[0].timestamp == pytest.approx(EXTRA_EVENT_TIME_SECONDS) # type: ignore
assert events[0].attributes == {"alpha": "beta"}
assert links[0].context.trace_id == "0606" * 8
assert links[0].attributes == {"delta": 1}
def test_resource_from_proto() -> None:
resource = ProtoResource()
_add_attribute(resource.attributes, "key", "value")
result = otlp._resource_from_proto(resource, schema_url="https://example/schema")
assert result.attributes == {"key": "value"}
assert result.schema_url == "https://example/schema"
def test_maybe_gzip_response_parses_quality_values() -> None:
request = SimpleNamespace(headers={"Accept-Encoding": "br, gzip;q=0.1"})
payload = b"payload"
compressed, headers = otlp._maybe_gzip_response(cast(Request, request), payload)
assert headers == {"Content-Encoding": "gzip"}
assert gzip.decompress(compressed) == payload
def test_bad_request_response_matches_request_encoding() -> None:
request = SimpleNamespace(headers={"Accept-Encoding": "gzip"})
response = otlp._bad_request_response(cast(Request, request), "error")
assert response.status_code == 400
assert response.media_type == otlp.PROTOBUF_CT
status = otlp.Status() # type: ignore[attr-defined]
status.ParseFromString(gzip.decompress(response.body))
assert status.message == "error"
class _DummyReadableSpan:
def __init__(self) -> None:
self._resource = Resource.create({"existing": "value"})
def test_lightning_store_otlp_exporter_overrides_resources(monkeypatch: pytest.MonkeyPatch) -> None:
exporter = otlp.LightningStoreOTLPExporter(endpoint="http://collector")
captured_spans: List[List[_DummyReadableSpan]] = []
def fake_export(self: otlp.LightningStoreOTLPExporter, spans: List[_DummyReadableSpan]) -> SpanExportResult:
captured_spans.append(spans)
return SpanExportResult.SUCCESS
monkeypatch.setattr(otlp.OTLPSpanExporter, "export", fake_export, raising=False)
exporter.enable_store_otlp("http://store", "rollout", "attempt")
span = _DummyReadableSpan()
result = exporter.export([cast(ReadableSpan, span)])
assert result == SpanExportResult.SUCCESS
assert captured_spans
attributes = captured_spans[0][0]._resource.attributes # type: ignore[attr-defined]
assert attributes[LightningResourceAttributes.ROLLOUT_ID.value] == "rollout"
assert attributes[LightningResourceAttributes.ATTEMPT_ID.value] == "attempt"
exporter.disable_store_otlp()
assert exporter._rollout_id is None
assert exporter._attempt_id is None
File diff suppressed because it is too large Load Diff
+140
View File
@@ -0,0 +1,140 @@
# Copyright (c) Microsoft. All rights reserved.
from __future__ import annotations
from types import SimpleNamespace
from typing import Any, Callable, Optional
import pytest
from agentlightning.utils import system_snapshot
pytestmark = pytest.mark.utils
def _patch_system_snapshot(
monkeypatch: pytest.MonkeyPatch,
include_gpu: bool = False,
cpu_percent_fn: Optional[Callable[[float], float]] = None,
) -> Optional[SimpleNamespace]:
monkeypatch.setattr(system_snapshot.platform, "processor", lambda: "test-cpu")
monkeypatch.setattr(system_snapshot.platform, "platform", lambda: "test-platform")
monkeypatch.setattr(system_snapshot.socket, "gethostname", lambda: "test-host")
def fake_cpu_count(logical: bool = True) -> int:
return 4 if logical else 2
monkeypatch.setattr(system_snapshot.psutil, "cpu_count", fake_cpu_count)
def default_cpu_percent(interval: float) -> float:
return 33.3
monkeypatch.setattr(
system_snapshot.psutil,
"cpu_percent",
cpu_percent_fn or default_cpu_percent,
)
vm = SimpleNamespace(used=5 * (2**30), total=10 * (2**30), percent=50.0)
monkeypatch.setattr(system_snapshot.psutil, "virtual_memory", lambda: vm)
du = SimpleNamespace(used=2 * (2**30), total=8 * (2**30), percent=25.0)
monkeypatch.setattr(system_snapshot.psutil, "disk_usage", lambda _: du) # type: ignore
net = SimpleNamespace(bytes_sent=4 * (2**20), bytes_recv=6 * (2**20))
monkeypatch.setattr(system_snapshot.psutil, "net_io_counters", lambda: net)
if include_gpu:
dummy_gpu = SimpleNamespace(
name="Test GPU",
utilization=37.5,
memory_used=1024,
memory_total=4096,
temperature=68,
)
monkeypatch.setattr(
system_snapshot.GPUStatCollection,
"new_query",
lambda *args, **kwargs: SimpleNamespace(gpus=[dummy_gpu]), # type: ignore
)
return dummy_gpu
return None
def test_system_snapshot_excludes_gpu_by_default(monkeypatch: pytest.MonkeyPatch) -> None:
def fail_gpu_query(*args: Any, **kwargs: Any) -> Any:
raise AssertionError("GPU stats should not be queried when include_gpu is False")
monkeypatch.setattr(system_snapshot.GPUStatCollection, "new_query", fail_gpu_query)
_patch_system_snapshot(monkeypatch)
snapshot = system_snapshot.system_snapshot()
assert snapshot["cpu_name"] == "test-cpu"
assert snapshot["cpu_cores"] == 2
assert snapshot["cpu_threads"] == 4
assert snapshot["cpu_usage_pct"] == 33.3
assert snapshot["mem_used_gb"] == 5.0
assert snapshot["mem_total_gb"] == 10.0
assert snapshot["mem_pct"] == 50.0
assert snapshot["disk_used_gb"] == 2.0
assert snapshot["disk_total_gb"] == 8.0
assert snapshot["disk_pct"] == 25.0
assert snapshot["bytes_sent_mb"] == 4.0
assert snapshot["bytes_recv_mb"] == 6.0
assert snapshot["host"] == "test-host"
assert snapshot["os"] == "test-platform"
assert "gpus" not in snapshot
def test_system_snapshot_includes_gpus_when_requested(monkeypatch: pytest.MonkeyPatch) -> None:
dummy_gpu = _patch_system_snapshot(monkeypatch, include_gpu=True)
assert dummy_gpu is not None
snapshot = system_snapshot.system_snapshot(include_gpu=True)
assert snapshot["gpus"] == [
{
"gpu": dummy_gpu.name,
"util_pct": dummy_gpu.utilization,
"mem_used_mb": dummy_gpu.memory_used,
"mem_total_mb": dummy_gpu.memory_total,
"temp_c": dummy_gpu.temperature,
}
]
def test_sanity_check() -> None:
snapshot = system_snapshot.system_snapshot()
assert snapshot is not None
snapshot = system_snapshot.system_snapshot(include_gpu=True)
assert snapshot is not None
try:
import torch # type: ignore
GPU_AVAILABLE = torch.cuda.is_available()
except Exception:
GPU_AVAILABLE = False # type: ignore
if GPU_AVAILABLE:
assert snapshot["gpus"] is not None
assert len(snapshot["gpus"]) > 0
def test_system_snapshot_cpu_percent_samples_immediately(monkeypatch: pytest.MonkeyPatch) -> None:
called_intervals: list[float] = []
def record_cpu_percent(interval: float) -> float:
called_intervals.append(interval)
return 77.7
_patch_system_snapshot(monkeypatch, cpu_percent_fn=record_cpu_percent)
snapshot = system_snapshot.system_snapshot()
assert snapshot["cpu_usage_pct"] == 77.7
assert called_intervals == [0.0]