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
619 lines
20 KiB
Python
619 lines
20 KiB
Python
# Copyright (c) Microsoft. All rights reserved.
|
|
|
|
# pyright: reportPrivateUsage=false
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import multiprocessing
|
|
import pickle
|
|
import threading
|
|
import time
|
|
from multiprocessing.connection import Connection
|
|
from typing import Any, Dict, List, Optional
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
|
|
import pytest
|
|
import uvicorn
|
|
from fastapi import FastAPI, Request
|
|
from opentelemetry.proto.collector.trace.v1.trace_service_pb2 import (
|
|
ExportTraceServiceRequest,
|
|
ExportTraceServiceResponse,
|
|
)
|
|
from opentelemetry.sdk.trace import ReadableSpan
|
|
from opentelemetry.trace import SpanContext, TraceFlags
|
|
from portpicker import pick_unused_port
|
|
|
|
from agentlightning.reward import emit_reward, find_reward_spans, get_reward_value, reward
|
|
from agentlightning.store.base import LightningStore
|
|
from agentlightning.tracer.agentops import LightningSpanProcessor
|
|
from agentlightning.tracer.otel import OtelTracer
|
|
from agentlightning.types import TraceStatus
|
|
from agentlightning.types.tracer import Span
|
|
from agentlightning.utils import otlp
|
|
|
|
from ..common.tracer import clear_tracer_provider
|
|
|
|
|
|
def create_span(name: str, sampled: bool = True, with_context: bool = True) -> ReadableSpan:
|
|
"""Helper to create mock spans with different properties."""
|
|
return ReadableSpan(
|
|
name=name,
|
|
context=(
|
|
SpanContext(
|
|
trace_id=hash(name) % (2**64),
|
|
span_id=hash(name) % (2**64),
|
|
is_remote=False,
|
|
trace_flags=TraceFlags(0x01 if sampled else 0x00),
|
|
)
|
|
if with_context
|
|
else None
|
|
),
|
|
)
|
|
|
|
|
|
async def add_otel_span(
|
|
rollout_id: str, attempt_id: str, readable_span: ReadableSpan, sequence_id: int | None = None
|
|
) -> Span:
|
|
span = Span.from_opentelemetry(
|
|
readable_span, rollout_id=rollout_id, attempt_id=attempt_id, sequence_id=sequence_id or 0
|
|
)
|
|
return span
|
|
|
|
|
|
def create_mock_store(otlp_supported: bool = False) -> MagicMock:
|
|
"""Helper to create a mock LightningStore."""
|
|
store = MagicMock(spec=LightningStore)
|
|
store.add_otel_span = AsyncMock(side_effect=add_otel_span)
|
|
store.capabilities = {"otlp_traces": otlp_supported}
|
|
store.otlp_traces_endpoint.return_value = "http://store/v1/traces"
|
|
return store
|
|
|
|
|
|
@pytest.fixture(params=[False, True], ids=["store-no-otlp", "store-otlp"])
|
|
def store_supports_otlp(request: pytest.FixtureRequest) -> bool:
|
|
return bool(request.param)
|
|
|
|
|
|
@pytest.fixture
|
|
def otlp_server(store_supports_otlp: bool):
|
|
if not store_supports_otlp:
|
|
yield None
|
|
return
|
|
|
|
app = FastAPI()
|
|
received: List[ExportTraceServiceRequest] = []
|
|
|
|
@app.post("/v1/traces")
|
|
async def _export_traces(request: Request): # type: ignore
|
|
async def capture(message: ExportTraceServiceRequest) -> None:
|
|
received.append(message)
|
|
|
|
return await otlp.handle_otlp_export(
|
|
request,
|
|
ExportTraceServiceRequest,
|
|
ExportTraceServiceResponse,
|
|
capture,
|
|
signal_name="traces",
|
|
)
|
|
|
|
port = pick_unused_port()
|
|
config = uvicorn.Config(app, host="127.0.0.1", port=port, log_level="error")
|
|
server = uvicorn.Server(config)
|
|
thread = threading.Thread(target=server.run, daemon=True)
|
|
thread.start()
|
|
timeout = time.time() + 5
|
|
while not getattr(server, "started", False):
|
|
if time.time() > timeout:
|
|
raise RuntimeError("OTLP test server failed to start")
|
|
if not thread.is_alive():
|
|
raise RuntimeError("OTLP test server thread exited before startup")
|
|
time.sleep(0.01)
|
|
|
|
try:
|
|
yield {"url": f"http://127.0.0.1:{port}/v1/traces", "received": received}
|
|
finally:
|
|
server.should_exit = True
|
|
thread.join(timeout=5)
|
|
|
|
|
|
@pytest.fixture
|
|
def store(store_supports_otlp: bool, otlp_server: Optional[Dict[str, Any]]) -> MagicMock:
|
|
mock_store = create_mock_store(store_supports_otlp)
|
|
if store_supports_otlp:
|
|
assert otlp_server is not None
|
|
mock_store.otlp_traces_endpoint.return_value = otlp_server["url"]
|
|
return mock_store
|
|
|
|
|
|
@pytest.fixture
|
|
def otel_tracer():
|
|
clear_tracer_provider()
|
|
tracer = OtelTracer()
|
|
with tracer.lifespan():
|
|
yield tracer
|
|
clear_tracer_provider()
|
|
|
|
|
|
def test_otel_tracer_create_span_records_fields(otel_tracer: OtelTracer) -> None:
|
|
timestamp = 2000.123
|
|
status = TraceStatus(status_code="ERROR", description="boom")
|
|
|
|
span = otel_tracer.create_span(
|
|
"otel-span",
|
|
attributes={"foo": "bar"},
|
|
timestamp=timestamp,
|
|
status=status,
|
|
)
|
|
|
|
assert span.name == "otel-span"
|
|
assert span.attributes["foo"] == "bar"
|
|
assert span.start_time == pytest.approx(timestamp) # type: ignore
|
|
assert span.end_time == pytest.approx(timestamp) # type: ignore
|
|
assert span.status.status_code == "ERROR"
|
|
assert span.status.description == "boom"
|
|
|
|
|
|
def test_otel_tracer_operation_context_records_span(otel_tracer: OtelTracer) -> None:
|
|
with pytest.raises(RuntimeError):
|
|
with otel_tracer.operation_context("otel-operation", attributes={"initial": "value"}) as ctx:
|
|
ctx.record_attributes({"custom": "attr"})
|
|
raise RuntimeError("otel failure")
|
|
|
|
recorded_span = ctx.get_recorded_span() # type: ignore
|
|
assert recorded_span.name == "otel-operation"
|
|
assert recorded_span.attributes["initial"] == "value"
|
|
assert recorded_span.attributes["custom"] == "attr"
|
|
assert recorded_span.status.status_code == "ERROR"
|
|
assert recorded_span.status.description is not None
|
|
assert "otel failure" in recorded_span.status.description
|
|
assert recorded_span.start_time is not None
|
|
assert recorded_span.end_time is not None
|
|
|
|
|
|
def test_initialization_and_shutdown():
|
|
"""Test processor lifecycle: initialization, loop thread, and shutdown."""
|
|
processor = LightningSpanProcessor()
|
|
|
|
# Verify initialization
|
|
assert processor._spans == []
|
|
assert processor._store is None
|
|
assert processor._rollout_id is None
|
|
assert processor._attempt_id is None
|
|
|
|
assert processor._loop is None
|
|
assert processor._loop_thread is None
|
|
|
|
# Start the loop
|
|
processor._ensure_loop()
|
|
|
|
# Verify loop thread is running correctly
|
|
assert processor._loop is not None
|
|
assert processor._loop.is_running()
|
|
assert processor._loop_thread is not None
|
|
assert processor._loop_thread.is_alive()
|
|
assert processor._loop_thread.daemon is True
|
|
assert processor._loop_thread.name == "otel-loop"
|
|
|
|
# Verify shutdown stops everything
|
|
thread = processor._loop_thread
|
|
processor.shutdown()
|
|
|
|
time.sleep(0.1) # Give thread time to stop
|
|
assert not thread.is_alive()
|
|
assert processor._loop is None
|
|
|
|
# Verify double shutdown is safe (idempotent)
|
|
processor.shutdown() # Should not raise
|
|
assert processor._loop is None
|
|
|
|
|
|
def test_span_collection_with_filtering():
|
|
"""Test that spans are collected with proper filtering."""
|
|
processor = LightningSpanProcessor()
|
|
|
|
sampled_span = create_span("sampled", sampled=True)
|
|
unsampled_span = create_span("unsampled", sampled=False)
|
|
no_context_span = create_span("no_context", with_context=False)
|
|
|
|
# Process different types of spans
|
|
processor.on_end(sampled_span)
|
|
processor.on_end(unsampled_span)
|
|
processor.on_end(no_context_span)
|
|
|
|
# Only sampled span with context should be collected
|
|
collected = processor.spans()
|
|
assert len(collected) == 1
|
|
assert collected[0].name == "sampled"
|
|
|
|
processor.shutdown()
|
|
|
|
|
|
def test_context_managers_clear_state(store: MagicMock):
|
|
"""Test that both context managers properly manage state."""
|
|
processor = LightningSpanProcessor()
|
|
|
|
# Add a span first
|
|
span1 = create_span("span1")
|
|
processor.on_end(span1)
|
|
assert len(processor.spans()) == 1
|
|
|
|
# Test basic context manager: __enter__ clears spans
|
|
with processor:
|
|
assert len(processor.spans()) == 0
|
|
span2 = create_span("span2")
|
|
processor.on_end(span2)
|
|
assert len(processor.spans()) == 1
|
|
|
|
# After exit, store context should be cleared
|
|
assert processor._store is None
|
|
assert processor._rollout_id is None
|
|
assert processor._attempt_id is None
|
|
|
|
# Test with_context: sets and clears store context
|
|
with processor.with_context(store=store, rollout_id="r1", attempt_id="a1") as proc:
|
|
assert proc is processor
|
|
assert processor._store is store
|
|
assert processor._rollout_id == "r1"
|
|
assert processor._attempt_id == "a1"
|
|
|
|
# After exit, context should be cleared
|
|
assert processor._store is None
|
|
assert processor._rollout_id is None
|
|
assert processor._attempt_id is None
|
|
|
|
processor.shutdown()
|
|
|
|
|
|
def test_store_integration_complete(store: MagicMock):
|
|
"""Test all store integration scenarios: writes, errors, timeout, thread verification."""
|
|
processor = LightningSpanProcessor()
|
|
|
|
# Test 1: Successful store writes
|
|
with processor.with_context(store=store, rollout_id="r1", attempt_id="a1"):
|
|
span1 = create_span("span1")
|
|
span2 = create_span("span2")
|
|
processor.on_end(span1)
|
|
processor.on_end(span2)
|
|
|
|
# Verify both spans written to store
|
|
assert store.add_otel_span.call_count == 2
|
|
assert len(processor.spans()) == 2
|
|
|
|
# Verify call arguments
|
|
calls = store.add_otel_span.call_args_list
|
|
assert calls[0][0] == ("r1", "a1", span1)
|
|
assert calls[1][0] == ("r1", "a1", span2)
|
|
|
|
# Test 2: Store write errors are caught and don't crash
|
|
store.add_otel_span.reset_mock()
|
|
store.add_otel_span.side_effect = RuntimeError("Store failure")
|
|
|
|
with processor.with_context(store=store, rollout_id="r2", attempt_id="a2"):
|
|
span3 = create_span("span3")
|
|
processor.on_end(span3) # Should not raise
|
|
|
|
# Span still collected despite store error
|
|
assert len(processor.spans()) == 1
|
|
store.add_otel_span.side_effect = None
|
|
|
|
# Test 3: Verify writes happen in loop thread
|
|
execution_thread = None
|
|
|
|
async def track_thread(*args: Any, **kwargs: Any):
|
|
nonlocal execution_thread
|
|
execution_thread = threading.current_thread()
|
|
|
|
store.add_otel_span = AsyncMock(side_effect=track_thread)
|
|
|
|
with processor.with_context(store=store, rollout_id="r3", attempt_id="a3"):
|
|
span4 = create_span("span4")
|
|
processor.on_end(span4)
|
|
|
|
assert execution_thread is not None
|
|
assert execution_thread.name == "otel-loop"
|
|
|
|
# Test 4: Unsampled and no-context spans don't trigger store writes
|
|
store.add_otel_span.reset_mock()
|
|
store.add_otel_span = AsyncMock()
|
|
|
|
with processor.with_context(store=store, rollout_id="r4", attempt_id="a4"):
|
|
unsampled = create_span("unsampled", sampled=False)
|
|
no_ctx = create_span("no_ctx", with_context=False)
|
|
processor.on_end(unsampled)
|
|
processor.on_end(no_ctx)
|
|
|
|
# Store should not be called for filtered spans
|
|
store.add_otel_span.assert_not_called()
|
|
|
|
processor.shutdown()
|
|
|
|
|
|
def test_event_loop_operations():
|
|
"""Test _await_in_loop: execution, exceptions, and timeout."""
|
|
processor = LightningSpanProcessor()
|
|
|
|
# Test 1: Successful coroutine execution
|
|
async def successful_coro():
|
|
await asyncio.sleep(0.01)
|
|
return "success"
|
|
|
|
result = processor._await_in_loop(successful_coro())
|
|
assert result == "success"
|
|
|
|
# Test 2: Exception propagation
|
|
async def failing_coro():
|
|
raise ValueError("Expected error")
|
|
|
|
with pytest.raises(ValueError, match="Expected error"):
|
|
processor._await_in_loop(failing_coro())
|
|
|
|
# Test 3: Timeout handling
|
|
async def slow_coro():
|
|
await asyncio.sleep(10)
|
|
return "too_slow"
|
|
|
|
with pytest.raises(Exception): # Should timeout
|
|
processor._await_in_loop(slow_coro(), timeout=0.1)
|
|
|
|
processor.shutdown()
|
|
|
|
|
|
def test_concurrent_access(store: MagicMock):
|
|
"""Test thread-safe concurrent span processing."""
|
|
processor = LightningSpanProcessor()
|
|
|
|
num_threads = 10
|
|
spans_per_thread = 5
|
|
barrier = threading.Barrier(num_threads)
|
|
|
|
def process_spans(thread_id: int) -> None:
|
|
barrier.wait() # Synchronize all threads to start together
|
|
for i in range(spans_per_thread):
|
|
span = create_span(f"thread{thread_id}_span{i}")
|
|
processor.on_end(span)
|
|
|
|
with processor.with_context(store=store, rollout_id="concurrent", attempt_id="test"):
|
|
threads = [threading.Thread(target=process_spans, args=(i,)) for i in range(num_threads)]
|
|
|
|
for t in threads:
|
|
t.start()
|
|
for t in threads:
|
|
t.join()
|
|
|
|
# All spans should be collected and written
|
|
assert len(processor.spans()) == num_threads * spans_per_thread
|
|
assert store.add_otel_span.call_count == num_threads * spans_per_thread
|
|
|
|
processor.shutdown()
|
|
|
|
|
|
def test_multiprocessing_behavior(store_supports_otlp: bool):
|
|
"""Test processor behavior across process boundaries."""
|
|
|
|
# Test 1: Creating new processor in subprocess works
|
|
def subprocess_task(result_queue: "multiprocessing.Queue[tuple[str, Any]]", supports_otlp: bool) -> None:
|
|
try:
|
|
processor = LightningSpanProcessor()
|
|
|
|
span = create_span("subprocess_span")
|
|
processor.on_end(span)
|
|
|
|
# Loop is not used
|
|
assert processor._loop is None
|
|
assert processor._loop_thread is None
|
|
|
|
with processor.with_context(store=create_mock_store(supports_otlp), rollout_id="r1", attempt_id="a1"):
|
|
processor.on_end(span)
|
|
|
|
# Verify processor works in new process
|
|
assert processor._loop is not None
|
|
assert processor._loop_thread is not None
|
|
assert processor._loop_thread.is_alive()
|
|
|
|
result_queue.put(("success", len(processor.spans())))
|
|
processor.shutdown()
|
|
except Exception as e:
|
|
result_queue.put(("error", str(e)))
|
|
|
|
result_queue: multiprocessing.Queue[tuple[str, Any]] = multiprocessing.Queue()
|
|
process = multiprocessing.Process(target=subprocess_task, args=(result_queue, store_supports_otlp))
|
|
process.start()
|
|
process.join(timeout=5)
|
|
|
|
assert not process.is_alive()
|
|
status, value = result_queue.get(timeout=1)
|
|
assert status == "success", f"Subprocess failed: {status}, {value}"
|
|
assert value == 1
|
|
|
|
# Test 2: Processor cannot be pickled (threads aren't picklable)
|
|
processor = LightningSpanProcessor()
|
|
|
|
with pytest.raises((TypeError, AttributeError)):
|
|
pickle.dumps(processor)
|
|
|
|
processor.shutdown()
|
|
|
|
|
|
def test_store_write_timeout(store: MagicMock):
|
|
"""Test that slow store writes respect timeout."""
|
|
processor = LightningSpanProcessor()
|
|
|
|
# Create a slow async function that exceeds timeout
|
|
async def slow_write(*args: Any, **kwargs: Any) -> None:
|
|
await asyncio.sleep(15)
|
|
|
|
store.add_otel_span = AsyncMock(side_effect=slow_write)
|
|
|
|
with processor.with_context(store=store, rollout_id="r1", attempt_id="a1"):
|
|
span = create_span("test_span")
|
|
# Should not raise - timeout is caught in on_end
|
|
processor.on_end(span)
|
|
|
|
# Span still collected despite timeout
|
|
# Note: The timeout in on_end is 5.0 seconds, but the error is caught
|
|
assert len(processor.spans()) == 1
|
|
|
|
processor.shutdown()
|
|
|
|
|
|
def test_multiple_processors_in_same_process():
|
|
"""Test that multiple processors can coexist in the same process."""
|
|
processor1 = LightningSpanProcessor()
|
|
processor2 = LightningSpanProcessor()
|
|
|
|
processor1._ensure_loop()
|
|
processor2._ensure_loop()
|
|
|
|
# Both should have independent loops and threads
|
|
assert processor1._loop is not processor2._loop
|
|
assert processor1._loop_thread is not processor2._loop_thread
|
|
assert processor1._loop is not None and processor1._loop.is_running()
|
|
assert processor2._loop is not None and processor2._loop.is_running()
|
|
|
|
# Both should work independently
|
|
span1 = create_span("p1_span")
|
|
span2 = create_span("p2_span")
|
|
|
|
processor1.on_end(span1)
|
|
processor2.on_end(span2)
|
|
|
|
assert len(processor1.spans()) == 1
|
|
assert len(processor2.spans()) == 1
|
|
assert processor1.spans()[0].name == "p1_span"
|
|
assert processor2.spans()[0].name == "p2_span"
|
|
|
|
processor1.shutdown()
|
|
processor2.shutdown()
|
|
|
|
|
|
def test_context_manager_reusability(store: MagicMock):
|
|
"""Test that context managers can be entered and exited multiple times."""
|
|
processor = LightningSpanProcessor()
|
|
|
|
# First usage
|
|
with processor.with_context(store=store, rollout_id="r1", attempt_id="a1"):
|
|
span1 = create_span("span1")
|
|
processor.on_end(span1)
|
|
|
|
assert len(processor.spans()) == 1
|
|
assert processor._store is None
|
|
|
|
# Second usage - should work fine
|
|
with processor.with_context(store=store, rollout_id="r2", attempt_id="a2"):
|
|
span2 = create_span("span2")
|
|
processor.on_end(span2)
|
|
|
|
# Spans should be cleared from first context, only span2 present
|
|
assert len(processor.spans()) == 1
|
|
assert processor.spans()[0].name == "span2"
|
|
assert processor._store is None
|
|
|
|
processor.shutdown()
|
|
|
|
|
|
def _otel_reward_subprocess(mode: str, conn: Connection[tuple[str, Any]]) -> None:
|
|
asyncio.run(_otel_reward_subprocess_async(mode, conn))
|
|
|
|
|
|
async def _otel_reward_subprocess_async(mode: str, conn: Connection[tuple[str, Any]]) -> None:
|
|
tracer: OtelTracer | None = None
|
|
try:
|
|
clear_tracer_provider()
|
|
|
|
tracer = OtelTracer()
|
|
tracer.init_worker(0)
|
|
|
|
if mode == "decorator":
|
|
expected_reward = 2.5
|
|
|
|
@reward
|
|
def compute_reward() -> float:
|
|
return expected_reward
|
|
|
|
async with tracer.trace_context(name="reward-decorator"):
|
|
returned = compute_reward()
|
|
if returned != expected_reward:
|
|
raise AssertionError(f"Expected reward {expected_reward}, got {returned}")
|
|
|
|
elif mode == "emit":
|
|
expected_reward = 4.5
|
|
async with tracer.trace_context(name="reward-emit"):
|
|
emit_reward(expected_reward)
|
|
else:
|
|
raise ValueError(f"Unsupported mode: {mode}")
|
|
|
|
spans = tracer.get_last_trace()
|
|
total_spans = len(spans)
|
|
reward_spans = find_reward_spans(spans)
|
|
if not reward_spans:
|
|
raise AssertionError("No reward spans captured by OtelTracer.")
|
|
|
|
actual_reward = get_reward_value(reward_spans[-1])
|
|
if actual_reward is None:
|
|
raise AssertionError("Captured reward span does not contain a reward value.")
|
|
|
|
conn.send(
|
|
(
|
|
"success",
|
|
{
|
|
"mode": mode,
|
|
"reward": actual_reward,
|
|
"expected_reward": expected_reward,
|
|
"total_spans": total_spans,
|
|
"reward_spans": len(reward_spans),
|
|
},
|
|
)
|
|
)
|
|
except Exception as exc: # pragma: no cover - defensive path
|
|
try:
|
|
conn.send(("error", repr(exc)))
|
|
except Exception:
|
|
pass
|
|
finally:
|
|
if tracer is not None:
|
|
tracer.teardown_worker(0)
|
|
conn.close()
|
|
|
|
|
|
def _run_otel_reward_test(mode: str) -> dict[str, Any]:
|
|
ctx = multiprocessing.get_context("spawn")
|
|
parent_conn, child_conn = ctx.Pipe(duplex=False)
|
|
proc = ctx.Process(target=_otel_reward_subprocess, args=(mode, child_conn))
|
|
proc.start()
|
|
proc.join(15.0)
|
|
|
|
if proc.is_alive():
|
|
proc.terminate()
|
|
proc.join(5)
|
|
pytest.fail(f"Subprocess for mode {mode!r} hung.")
|
|
|
|
try:
|
|
if not parent_conn.poll(1.0):
|
|
pytest.fail(f"No result from subprocess for mode {mode!r}: timed out")
|
|
status, payload = parent_conn.recv()
|
|
finally:
|
|
parent_conn.close()
|
|
|
|
if status != "success":
|
|
pytest.fail(f"Subprocess for mode {mode!r} failed: {payload}")
|
|
|
|
assert proc.exitcode == 0, f"Subprocess for mode {mode!r} exited with code {proc.exitcode}"
|
|
return payload
|
|
|
|
|
|
def test_otel_tracer_captures_reward_from_decorator():
|
|
payload = _run_otel_reward_test("decorator")
|
|
assert payload["reward_spans"] == 1
|
|
assert payload["total_spans"] == 1
|
|
assert payload["reward"] == pytest.approx(payload["expected_reward"]) # pyright: ignore[reportUnknownMemberType]
|
|
assert payload["expected_reward"] == pytest.approx(2.5) # pyright: ignore[reportUnknownMemberType]
|
|
|
|
|
|
def test_otel_tracer_captures_reward_from_emit_reward():
|
|
payload = _run_otel_reward_test("emit")
|
|
assert payload["reward_spans"] == 1
|
|
assert payload["total_spans"] == 1
|
|
assert payload["reward"] == pytest.approx(payload["expected_reward"]) # pyright: ignore[reportUnknownMemberType]
|
|
assert payload["expected_reward"] == pytest.approx(4.5) # pyright: ignore[reportUnknownMemberType]
|