Replace AgentOps mock server with bypassable client (#202)
--------- Co-authored-by: Hao Ni (CSI Interfusion Co Ltd) <v-nhao@microsoft.com>
This commit is contained in:
@@ -429,7 +429,16 @@ class TraceTree:
|
||||
If we don't, when we want to select the LLM completion span with agent as filter.
|
||||
We will never get the correct span underneath.
|
||||
"""
|
||||
# If the current node has only one child, recursively repair its hierarchy directly.
|
||||
# This special-case handling is needed because when a trace is manually ended
|
||||
# (via agentops.end_trace), the AgentOps provider automatically wraps all spans
|
||||
# under an extra synthetic root node (e.g., "run_one.session").
|
||||
if len(self.children) == 1:
|
||||
self.children[0].repair_hierarchy()
|
||||
return
|
||||
|
||||
nodes_to_repair = list(self.children)
|
||||
|
||||
for repair_node in nodes_to_repair:
|
||||
if len(self.children) == 1:
|
||||
# If there is only one child, we don't need to repair the hierarchy.
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import time
|
||||
from typing import Iterable
|
||||
|
||||
from agentlightning.instrumentation.agentops import AgentOpsServerManager
|
||||
|
||||
|
||||
def main(argv: Iterable[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description="Start AgentOps server")
|
||||
parser.add_argument("--daemon", action="store_true", help="Run server as a daemon")
|
||||
parser.add_argument("--port", type=int, default=8002, help="Port to run the server on")
|
||||
args = parser.parse_args(list(argv) if argv is not None else None)
|
||||
|
||||
manager = AgentOpsServerManager(daemon=args.daemon, port=args.port)
|
||||
try:
|
||||
manager.start()
|
||||
# Wait forever
|
||||
while True:
|
||||
time.sleep(1)
|
||||
except KeyboardInterrupt:
|
||||
manager.stop()
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -4,28 +4,68 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import multiprocessing
|
||||
import signal
|
||||
import socket
|
||||
import time
|
||||
from typing import Any, Callable, no_type_check
|
||||
|
||||
import flask
|
||||
import requests
|
||||
import setproctitle
|
||||
from agentops.client.api import V3Client, V4Client
|
||||
from agentops.client.api.types import AuthTokenResponse
|
||||
from agentops.sdk.exporters import AuthenticatedOTLPExporter
|
||||
from opentelemetry.exporter.otlp.proto.http.metric_exporter import OTLPMetricExporter
|
||||
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
|
||||
from opentelemetry.sdk.metrics.export import MetricExportResult
|
||||
from opentelemetry.sdk.trace.export import SpanExportResult
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
__all__ = [
|
||||
"instrument_agentops",
|
||||
"uninstrument_agentops",
|
||||
"agentops_local_server",
|
||||
"AgentOpsServerManager",
|
||||
]
|
||||
|
||||
# Module-level storage for originals
|
||||
_original_handle_chat_attributes: Callable[..., Any] | None = None
|
||||
_original_handle_response: Callable[..., Any] | None = None
|
||||
_agentops_service_enabled = False
|
||||
|
||||
|
||||
def enable_agentops_service(enabled: bool = True) -> None:
|
||||
"""
|
||||
Enable or disable communication with the AgentOps service.
|
||||
|
||||
False (default): AgentOps exporters and clients will run in local mode
|
||||
and will not attempt to communicate with the remote AgentOps service.
|
||||
True: all exporters and clients will operate in normal mode and send data
|
||||
to the AgentOps service as expected.
|
||||
"""
|
||||
global _agentops_service_enabled
|
||||
_agentops_service_enabled = enabled
|
||||
logger.info(f"Switch set to {enabled} for exporters and clients.")
|
||||
|
||||
|
||||
def _patch_exporters():
|
||||
import agentops.client.api
|
||||
import agentops.sdk.core
|
||||
import opentelemetry.exporter.otlp.proto.http.metric_exporter
|
||||
import opentelemetry.exporter.otlp.proto.http.trace_exporter
|
||||
|
||||
agentops.sdk.core.AuthenticatedOTLPExporter = BypassableAuthenticatedOTLPExporter # type: ignore
|
||||
opentelemetry.exporter.otlp.proto.http.metric_exporter.OTLPMetricExporter = BypassableOTLPMetricExporter
|
||||
opentelemetry.exporter.otlp.proto.http.trace_exporter.OTLPSpanExporter = BypassableOTLPSpanExporter
|
||||
agentops.client.api.V3Client = BypassableV3Client
|
||||
agentops.client.api.V4Client = BypassableV4Client
|
||||
|
||||
|
||||
def _unpatch_exporters():
|
||||
import agentops.client.api
|
||||
import agentops.sdk.core
|
||||
import opentelemetry.exporter.otlp.proto.http.metric_exporter
|
||||
import opentelemetry.exporter.otlp.proto.http.trace_exporter
|
||||
|
||||
agentops.sdk.core.AuthenticatedOTLPExporter = AuthenticatedOTLPExporter # type: ignore
|
||||
opentelemetry.exporter.otlp.proto.http.metric_exporter.OTLPMetricExporter = OTLPMetricExporter
|
||||
opentelemetry.exporter.otlp.proto.http.trace_exporter.OTLPSpanExporter = OTLPSpanExporter
|
||||
agentops.client.api.V3Client = V3Client
|
||||
agentops.client.api.V4Client = V4Client
|
||||
|
||||
|
||||
def _unwrap_legacy_response(response: Any) -> Any:
|
||||
@@ -170,6 +210,8 @@ def instrument_agentops():
|
||||
Instrument agentops to capture token IDs.
|
||||
Automatically detects and uses the appropriate patching method based on the installed agentops version.
|
||||
"""
|
||||
_patch_exporters()
|
||||
|
||||
# Try newest version first (tested for 0.4.16)
|
||||
try:
|
||||
return _patch_new_agentops()
|
||||
@@ -189,6 +231,8 @@ def instrument_agentops():
|
||||
|
||||
def uninstrument_agentops():
|
||||
"""Uninstrument agentops to stop capturing token IDs."""
|
||||
_unpatch_exporters()
|
||||
|
||||
try:
|
||||
_unpatch_new_agentops()
|
||||
except Exception:
|
||||
@@ -199,114 +243,75 @@ def uninstrument_agentops():
|
||||
pass
|
||||
|
||||
|
||||
def agentops_local_server():
|
||||
class BypassableAuthenticatedOTLPExporter(AuthenticatedOTLPExporter):
|
||||
"""
|
||||
Returns a Flask app that can be used to test agentops integration.
|
||||
This server provides endpoints for token fetching and a catch-all endpoint.
|
||||
AuthenticatedOTLPExporter with switchable service control.
|
||||
When `_agentops_service_enabled` is False, skip export and return success.
|
||||
"""
|
||||
app = flask.Flask(__name__)
|
||||
|
||||
@app.route("/v3/auth/token", methods=["POST"])
|
||||
def fetch_token(): # type: ignore
|
||||
return {"token": "dummy", "project_id": "dummy"}
|
||||
|
||||
@app.route("/", defaults={"path": ""}, methods=["GET", "POST"])
|
||||
@app.route("/<path:path>", methods=["GET", "POST"])
|
||||
def catch_all(path: str): # type: ignore
|
||||
return {"path": path}
|
||||
|
||||
return app
|
||||
|
||||
|
||||
def _run_server(**kwargs: Any): # type: ignore
|
||||
"""
|
||||
Internal function to run the Flask server.
|
||||
This is used to avoid issues with multiprocessing and Flask's reloader.
|
||||
"""
|
||||
signal.signal(signal.SIGINT, signal.SIG_IGN) # Ignore SIGINT in worker processes
|
||||
setproctitle.setproctitle(multiprocessing.current_process().name)
|
||||
app = agentops_local_server()
|
||||
app.run(**kwargs)
|
||||
|
||||
|
||||
class AgentOpsServerManager:
|
||||
"""Manages a AgentOps local server to bypass the online service of AgentOps."""
|
||||
|
||||
def __init__(self, daemon: bool = True, port: int | None = None):
|
||||
self.server_process: multiprocessing.Process | None = None
|
||||
self.server_port = port
|
||||
self.daemon = daemon
|
||||
logger.info("AgentOpsServerManager initialized.")
|
||||
|
||||
def _find_available_port(self) -> int:
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||
s.bind(("", 0))
|
||||
return s.getsockname()[1]
|
||||
|
||||
def start(self):
|
||||
if self.server_process and self.server_process.is_alive():
|
||||
logger.warning("AgentOps server process appears to be already running.")
|
||||
return
|
||||
|
||||
if self.server_port is None:
|
||||
self.server_port = self._find_available_port()
|
||||
|
||||
logger.info(f"Starting AgentOps local server on port {self.server_port}...")
|
||||
|
||||
self.server_process = multiprocessing.Process(
|
||||
target=_run_server,
|
||||
kwargs={"host": "127.0.0.1", "port": self.server_port, "use_reloader": False, "debug": False},
|
||||
daemon=self.daemon,
|
||||
name="AgentLightning-AgentOpsServer",
|
||||
)
|
||||
self.server_process.start()
|
||||
logger.info(
|
||||
f"AgentOps local server process (PID: {self.server_process.pid}) started, targeting port {self.server_port}."
|
||||
)
|
||||
for attempt in range(20): # 10 seconds total
|
||||
time.sleep(0.5) # Brief wait for server to start up
|
||||
try:
|
||||
result = requests.get(f"http://127.0.0.1:{self.server_port}/")
|
||||
if result.status_code == 200:
|
||||
break
|
||||
except Exception as e:
|
||||
logger.debug(f"Error checking AgentOps server: {e}")
|
||||
logger.warning(f"AgentOps still not ready after {attempt} attempts. Retrying...")
|
||||
def export(self, *args: Any, **kwargs: Any) -> SpanExportResult:
|
||||
if _agentops_service_enabled:
|
||||
return super().export(*args, **kwargs)
|
||||
else:
|
||||
logger.error(f"AgentOps local server failed to start or exited prematurely.")
|
||||
return
|
||||
logger.debug("SwitchableAuthenticatedOTLPExporter is switched off, skipping export.")
|
||||
return SpanExportResult.SUCCESS
|
||||
|
||||
if not self.server_process.is_alive():
|
||||
logger.error(f"AgentOps local server failed to start or exited prematurely.")
|
||||
|
||||
def is_alive(self) -> bool:
|
||||
if self.server_process and self.server_process.is_alive():
|
||||
return True
|
||||
return False
|
||||
class BypassableOTLPMetricExporter(OTLPMetricExporter):
|
||||
"""
|
||||
OTLPMetricExporter with switchable service control.
|
||||
When `_agentops_service_enabled` is False, skip export and return success.
|
||||
"""
|
||||
|
||||
def stop(self):
|
||||
if self.server_process is not None and self.server_process.is_alive():
|
||||
logger.info(f"Stopping AgentOps local server (PID: {self.server_process.pid})...")
|
||||
self.server_process.terminate() # Send SIGTERM
|
||||
self.server_process.join(timeout=5) # Wait for clean exit
|
||||
if self.server_process.is_alive():
|
||||
logger.warning(
|
||||
f"AgentOps server (PID: {self.server_process.pid}) did not terminate gracefully, killing..."
|
||||
)
|
||||
self.server_process.kill() # Force kill
|
||||
self.server_process.join(timeout=10) # Wait for kill
|
||||
self.server_process = None
|
||||
logger.info(f"AgentOps local server stopped.")
|
||||
def export(self, *args: Any, **kwargs: Any) -> MetricExportResult:
|
||||
if _agentops_service_enabled:
|
||||
return super().export(*args, **kwargs) # type: ignore[reportUnknownMemberType]
|
||||
else:
|
||||
logger.info("AgentOps local server was not running or already stopped.")
|
||||
logger.debug("SwitchableOTLPMetricExporter is switched off, skipping export.")
|
||||
return MetricExportResult.SUCCESS
|
||||
|
||||
def get_port(self) -> int | None:
|
||||
# Check liveness again in case it died since start()
|
||||
if self.is_alive() and self.server_port is not None:
|
||||
return self.server_port
|
||||
# If called after server stopped or failed, port might be stale or None
|
||||
if self.server_port is not None and (self.server_process is None or not self.server_process.is_alive()):
|
||||
logger.warning(
|
||||
f"AgentOps server port {self.server_port} is stored, but server process is not alive. Returning stored port."
|
||||
)
|
||||
return self.server_port
|
||||
|
||||
class BypassableOTLPSpanExporter(OTLPSpanExporter):
|
||||
"""
|
||||
OTLPSpanExporter with switchable service control.
|
||||
When `_agentops_service_enabled` is False, skip export and return success.
|
||||
"""
|
||||
|
||||
def export(self, *args: Any, **kwargs: Any) -> SpanExportResult:
|
||||
if _agentops_service_enabled:
|
||||
return super().export(*args, **kwargs)
|
||||
else:
|
||||
logger.debug("SwitchableOTLPSpanExporter is switched off, skipping export.")
|
||||
return SpanExportResult.SUCCESS
|
||||
|
||||
|
||||
class BypassableV3Client(V3Client):
|
||||
"""
|
||||
V3Client with toggleable authentication calls.
|
||||
Returns dummy auth response when `_agentops_service_enabled` is False.
|
||||
"""
|
||||
|
||||
# Temporary synchronous override of fetch_auth_token for mock purposes.
|
||||
def fetch_auth_token(self, *args: Any, **kwargs: Any) -> AuthTokenResponse: # type: ignore[override]
|
||||
if _agentops_service_enabled:
|
||||
return super().fetch_auth_token(*args, **kwargs) # type: ignore[override]
|
||||
else:
|
||||
logger.debug("SwitchableV3Client is switched off, skipping fetch_auth_token request.")
|
||||
return AuthTokenResponse(token="dummy", project_id="dummy")
|
||||
|
||||
|
||||
class BypassableV4Client(V4Client):
|
||||
"""
|
||||
V4Client with toggleable post requests.
|
||||
Returns dummy response when `_agentops_service_enabled` is False.
|
||||
"""
|
||||
|
||||
def post(self, *args: Any, **kwargs: Any) -> requests.Response:
|
||||
if _agentops_service_enabled:
|
||||
return super().post(*args, **kwargs)
|
||||
else:
|
||||
logger.debug("SwitchableV4Client is switched off, skipping post request.")
|
||||
response = requests.Response()
|
||||
response.status_code = 200
|
||||
response._content = b"{}"
|
||||
return response
|
||||
|
||||
@@ -15,9 +15,9 @@ from agentops.sdk.core import TracingCore
|
||||
from agentops.sdk.processors import SpanProcessor
|
||||
from opentelemetry.instrumentation.utils import suppress_instrumentation
|
||||
from opentelemetry.sdk.trace import ReadableSpan
|
||||
from opentelemetry.trace.status import StatusCode
|
||||
|
||||
from agentlightning.instrumentation import instrument_all, uninstrument_all
|
||||
from agentlightning.instrumentation.agentops import AgentOpsServerManager
|
||||
from agentlightning.store.base import LightningStore
|
||||
|
||||
from .base import Tracer
|
||||
@@ -56,46 +56,11 @@ class AgentOpsTracer(Tracer):
|
||||
self.instrument_managed = instrument_managed
|
||||
self.daemon = daemon
|
||||
|
||||
self._agentops_server_manager = AgentOpsServerManager(self.daemon)
|
||||
self._agentops_server_port_val: Optional[int] = None
|
||||
|
||||
if not self.agentops_managed:
|
||||
logger.warning("agentops_managed=False. You are responsible for AgentOps setup.")
|
||||
if not self.instrument_managed:
|
||||
logger.warning("instrument_managed=False. You are responsible for all instrumentation.")
|
||||
|
||||
def __getstate__(self):
|
||||
state = self.__dict__.copy()
|
||||
state["_agentops_server_manager"] = None # Exclude the unpicklable server manager
|
||||
# _agentops_server_port_val (int) is inherently picklable and will be included.
|
||||
logger.debug(f"Getting state for pickling Trainer (PID {os.getpid()}). _agentops_server_manager excluded.")
|
||||
return state
|
||||
|
||||
def __setstate__(self, state: Any):
|
||||
self.__dict__.update(state)
|
||||
# In child process, self._agentops_server_manager will be None.
|
||||
logger.debug(f"Setting state for unpickled Trainer (PID {os.getpid()}). _agentops_server_manager is None.")
|
||||
|
||||
def init(self, *args: Any, **kwargs: Any):
|
||||
if self.agentops_managed and self._agentops_server_manager:
|
||||
self._agentops_server_manager.start()
|
||||
self._agentops_server_port_val = self._agentops_server_manager.get_port()
|
||||
if self._agentops_server_port_val is None:
|
||||
if (
|
||||
self._agentops_server_manager.server_process is not None
|
||||
and self._agentops_server_manager.server_process.is_alive()
|
||||
):
|
||||
raise RuntimeError("AgentOps server started but port is None. Check server manager logic.")
|
||||
elif (
|
||||
self._agentops_server_port_val is None and self._agentops_server_manager.server_process is None
|
||||
): # Server failed to start
|
||||
raise RuntimeError("AgentOps server manager indicates server is not running and port is None.")
|
||||
|
||||
def teardown(self):
|
||||
if self.agentops_managed:
|
||||
self._agentops_server_manager.stop()
|
||||
logger.info("AgentOps server stopped.")
|
||||
|
||||
def instrument(self, worker_id: int):
|
||||
instrument_all()
|
||||
|
||||
@@ -111,24 +76,9 @@ class AgentOpsTracer(Tracer):
|
||||
logger.info(f"[Worker {worker_id}] Instrumentation applied.")
|
||||
|
||||
if self.agentops_managed:
|
||||
if self._agentops_server_port_val: # Use the stored, picklable port value
|
||||
base_url = f"http://localhost:{self._agentops_server_port_val}"
|
||||
env_vars_to_set = {
|
||||
"AGENTOPS_API_KEY": "dummy",
|
||||
"AGENTOPS_API_ENDPOINT": base_url,
|
||||
"AGENTOPS_APP_URL": f"{base_url}/notavailable",
|
||||
"AGENTOPS_EXPORTER_ENDPOINT": f"{base_url}/traces",
|
||||
}
|
||||
for key, value in env_vars_to_set.items():
|
||||
os.environ[key] = value
|
||||
logger.info(f"[Worker {worker_id}] Env var set: {key}={value}")
|
||||
else:
|
||||
logger.warning(
|
||||
f"[Worker {worker_id}] AgentOps managed, but local server port is not available. Client may not connect as expected."
|
||||
)
|
||||
|
||||
os.environ.setdefault("AGENTOPS_API_KEY", "dummy")
|
||||
if not agentops.get_client().initialized:
|
||||
agentops.init() # type: ignore
|
||||
agentops.init(auto_start_session=False) # type: ignore
|
||||
logger.info(f"[Worker {worker_id}] AgentOps client initialized.")
|
||||
else:
|
||||
logger.warning(f"[Worker {worker_id}] AgentOps client was already initialized.")
|
||||
@@ -192,15 +142,30 @@ class AgentOpsTracer(Tracer):
|
||||
if not self._lightning_span_processor:
|
||||
raise RuntimeError("LightningSpanProcessor is not initialized. Call init_worker() first.")
|
||||
|
||||
if store is not None and rollout_id is not None and attempt_id is not None:
|
||||
ctx = self._lightning_span_processor.with_context(store=store, rollout_id=rollout_id, attempt_id=attempt_id)
|
||||
with ctx as processor:
|
||||
yield processor
|
||||
elif store is None and rollout_id is None and attempt_id is None:
|
||||
with self._lightning_span_processor:
|
||||
yield self._lightning_span_processor
|
||||
else:
|
||||
raise ValueError("store, rollout_id, and attempt_id must be either all provided or all None")
|
||||
kwargs: dict[str, Any] = {}
|
||||
if name is not None:
|
||||
kwargs["trace_name"] = name
|
||||
elif rollout_id is not None:
|
||||
kwargs["trace_name"] = rollout_id
|
||||
trace = agentops.start_trace(**kwargs)
|
||||
status = StatusCode.OK # type: ignore
|
||||
try:
|
||||
if store is not None and rollout_id is not None and attempt_id is not None:
|
||||
ctx = self._lightning_span_processor.with_context(
|
||||
store=store, rollout_id=rollout_id, attempt_id=attempt_id
|
||||
)
|
||||
with ctx as processor:
|
||||
yield processor
|
||||
elif store is None and rollout_id is None and attempt_id is None:
|
||||
with self._lightning_span_processor:
|
||||
yield self._lightning_span_processor
|
||||
else:
|
||||
raise ValueError("store, rollout_id, and attempt_id must be either all provided or all None")
|
||||
except Exception as e:
|
||||
status = StatusCode.ERROR # type: ignore
|
||||
logger.error(f"Trace failed for rollout_id={rollout_id}, attempt_id={attempt_id}, error={e}")
|
||||
finally:
|
||||
agentops.end_trace(trace, end_state=status) # type: ignore
|
||||
|
||||
def get_last_trace(self) -> List[ReadableSpan]:
|
||||
"""
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from opentelemetry.sdk.metrics.export import MetricExportResult
|
||||
from opentelemetry.sdk.trace.export import SpanExportResult
|
||||
|
||||
from agentlightning.instrumentation.agentops import (
|
||||
BypassableAuthenticatedOTLPExporter,
|
||||
BypassableOTLPMetricExporter,
|
||||
BypassableOTLPSpanExporter,
|
||||
enable_agentops_service,
|
||||
)
|
||||
|
||||
|
||||
def test_switchable_authenticated_exporter():
|
||||
switchable_authenticated_exporter = BypassableAuthenticatedOTLPExporter(endpoint="http://dummy", jwt="dummy")
|
||||
|
||||
with patch.object(
|
||||
switchable_authenticated_exporter.__class__.__bases__[0], "export", return_value=SpanExportResult.SUCCESS
|
||||
) as mock_export:
|
||||
enable_agentops_service()
|
||||
result = switchable_authenticated_exporter.export([])
|
||||
assert result == SpanExportResult.SUCCESS
|
||||
mock_export.assert_called_once()
|
||||
|
||||
enable_agentops_service(False)
|
||||
result = switchable_authenticated_exporter.export([])
|
||||
assert result == SpanExportResult.SUCCESS
|
||||
assert mock_export.call_count == 1
|
||||
|
||||
|
||||
def test_switchable_otlp_metric_exporter():
|
||||
|
||||
switchable_otlp_metric_exporter = BypassableOTLPMetricExporter()
|
||||
with patch.object(
|
||||
switchable_otlp_metric_exporter.__class__.__bases__[0], "export", return_value=MetricExportResult.SUCCESS
|
||||
) as mock_export:
|
||||
enable_agentops_service()
|
||||
result = switchable_otlp_metric_exporter.export(metrics_data=MagicMock())
|
||||
assert result == MetricExportResult.SUCCESS
|
||||
mock_export.assert_called_once()
|
||||
|
||||
enable_agentops_service(False)
|
||||
result = switchable_otlp_metric_exporter.export(metrics_data=MagicMock())
|
||||
assert result == MetricExportResult.SUCCESS
|
||||
assert mock_export.call_count == 1
|
||||
|
||||
|
||||
def test_switchable_otlp_span_exporter():
|
||||
|
||||
switchable_otlp_span_exporter = BypassableOTLPSpanExporter()
|
||||
with patch.object(
|
||||
switchable_otlp_span_exporter.__class__.__bases__[0], "export", return_value=SpanExportResult.SUCCESS
|
||||
) as mock_export:
|
||||
enable_agentops_service()
|
||||
result = switchable_otlp_span_exporter.export([])
|
||||
assert result == SpanExportResult.SUCCESS
|
||||
mock_export.assert_called_once()
|
||||
|
||||
enable_agentops_service(False)
|
||||
result = switchable_otlp_span_exporter.export([])
|
||||
assert result == SpanExportResult.SUCCESS
|
||||
assert mock_export.call_count == 1
|
||||
@@ -0,0 +1,77 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import multiprocessing
|
||||
from typing import Any, Optional, Union
|
||||
|
||||
import agentops
|
||||
from agentops.sdk.core import TraceContext
|
||||
from opentelemetry.trace.status import StatusCode
|
||||
|
||||
from agentlightning.tracer.agentops import AgentOpsTracer
|
||||
|
||||
|
||||
def _func_with_exception():
|
||||
"""Function that always raises an exception to test error tracing."""
|
||||
raise ValueError("This is a test exception")
|
||||
|
||||
|
||||
def _func_without_exception():
|
||||
"""Function that always executed successfully to test success tracing."""
|
||||
pass
|
||||
|
||||
|
||||
def test_trace_error_status_from_instance():
|
||||
"""
|
||||
Test that AgentOpsTracer correctly sets trace end state based on execution result.
|
||||
|
||||
This test replaces `agentops.end_trace` with a custom function to capture
|
||||
the `end_state` passed in. It verifies that traces ending after a raised
|
||||
exception have `StatusCode.ERROR`, while normal runs have `StatusCode.OK`.
|
||||
"""
|
||||
|
||||
ctx = multiprocessing.get_context("spawn")
|
||||
proc = ctx.Process(target=_test_trace_error_status_from_instance_imp)
|
||||
proc.start()
|
||||
proc.join(30.0) # On GPU server, the time is around 10 seconds.
|
||||
|
||||
if proc.is_alive():
|
||||
proc.terminate()
|
||||
proc.join(5)
|
||||
if proc.is_alive():
|
||||
proc.kill()
|
||||
|
||||
assert False, "Child process hung. Check test output for details."
|
||||
|
||||
assert proc.exitcode == 0, (
|
||||
f"Child process for test_trace_error_status_from_instance failed with exit code {proc.exitcode}. "
|
||||
"Check child traceback in test output."
|
||||
)
|
||||
|
||||
|
||||
def _test_trace_error_status_from_instance_imp():
|
||||
captured_state = {}
|
||||
old_end_trace = agentops.end_trace
|
||||
|
||||
def custom_end_trace(
|
||||
trace_context: Optional[TraceContext] = None, end_state: Union[Any, StatusCode, str] = None
|
||||
) -> None:
|
||||
captured_state["state"] = end_state
|
||||
return old_end_trace(trace_context, end_state=end_state)
|
||||
|
||||
agentops.end_trace = custom_end_trace
|
||||
|
||||
tracer = AgentOpsTracer()
|
||||
tracer.init()
|
||||
tracer.init_worker(0)
|
||||
|
||||
try:
|
||||
tracer.trace_run(_func_with_exception)
|
||||
assert captured_state["state"] == StatusCode.ERROR
|
||||
|
||||
tracer.trace_run(_func_without_exception)
|
||||
assert captured_state["state"] == StatusCode.OK
|
||||
|
||||
finally:
|
||||
agentops.end_trace = old_end_trace
|
||||
tracer.teardown_worker(0)
|
||||
tracer.teardown()
|
||||
Reference in New Issue
Block a user