diff --git a/agentops/__init__.py b/agentops/__init__.py index 816e77443..080ba4ff9 100755 --- a/agentops/__init__.py +++ b/agentops/__init__.py @@ -27,6 +27,7 @@ from agentops.client import Client from agentops.sdk.core import TraceContext, tracer from agentops.sdk.decorators import trace, session, agent, task, workflow, operation, tool, guardrail, track_endpoint +from agentops.sdk.decorators import error as error_decorator, log as log_decorator from agentops.enums import TraceState, SUCCESS, ERROR, UNSET from opentelemetry.trace.status import StatusCode @@ -447,6 +448,46 @@ def extract_key_from_attr(attr_value: str) -> str: return False +def error(name: str = "error", message: str = "", **attributes: Any) -> None: + """Record an error event as a one-shot span. + + Creates and immediately closes a span of kind ERROR with the given message. + + Args: + name: Name of the error event. + message: Error message or description. + **attributes: Additional attributes to set on the span. + """ + from agentops.sdk.decorators.utility import _create_as_current_span + + extra = {k: str(v) for k, v in attributes.items()} + if message: + extra["error.message"] = message + with _create_as_current_span(name, SpanKind.ERROR, attributes=extra) as span: + span.set_status(StatusCode.ERROR, message) + + +def log(name: str = "log", message: str = "", level: str = "INFO", **attributes: Any) -> None: + """Record a log event as a one-shot span. + + Creates and immediately closes a span of kind LOG with the given message and level. + + Args: + name: Name of the log event. + message: Log message content. + level: Log level (e.g. DEBUG, INFO, WARNING, ERROR). + **attributes: Additional attributes to set on the span. + """ + from agentops.sdk.decorators.utility import _create_as_current_span + + extra = {k: str(v) for k, v in attributes.items()} + if message: + extra["log.message"] = message + extra["log.level"] = level + with _create_as_current_span(name, SpanKind.LOG, attributes=extra): + pass + + __all__ = [ # Legacy exports "start_session", @@ -476,6 +517,11 @@ def extract_key_from_attr(attr_value: str) -> str: "tool", "guardrail", "track_endpoint", + "error_decorator", + "log_decorator", + # Standalone span functions + "error", + "log", # Enums "TraceState", "SUCCESS", diff --git a/agentops/sdk/decorators/__init__.py b/agentops/sdk/decorators/__init__.py index 14594329a..9932804b4 100644 --- a/agentops/sdk/decorators/__init__.py +++ b/agentops/sdk/decorators/__init__.py @@ -17,6 +17,8 @@ operation = task guardrail = create_entity_decorator(SpanKind.GUARDRAIL) track_endpoint = create_entity_decorator(SpanKind.HTTP) +error = create_entity_decorator(SpanKind.ERROR) +log = create_entity_decorator(SpanKind.LOG) # For backward compatibility: @session decorator calls @trace decorator @@ -48,4 +50,6 @@ def session(*args, **kwargs): # noqa: F811 "tool", "guardrail", "track_endpoint", + "error", + "log", ] diff --git a/agentops/semconv/span_kinds.py b/agentops/semconv/span_kinds.py index cb3a0ba36..da1562662 100644 --- a/agentops/semconv/span_kinds.py +++ b/agentops/semconv/span_kinds.py @@ -17,6 +17,8 @@ class AgentOpsSpanKindValues(Enum): TEXT = "text" GUARDRAIL = "guardrail" HTTP = "http" + ERROR = "error" + LOG = "log" UNKNOWN = "unknown" @@ -46,3 +48,5 @@ class SpanKind: TEXT = AgentOpsSpanKindValues.TEXT.value GUARDRAIL = AgentOpsSpanKindValues.GUARDRAIL.value HTTP = AgentOpsSpanKindValues.HTTP.value + ERROR = AgentOpsSpanKindValues.ERROR.value + LOG = AgentOpsSpanKindValues.LOG.value diff --git a/tests/unit/sdk/test_error_log_spans.py b/tests/unit/sdk/test_error_log_spans.py new file mode 100644 index 000000000..bf82ff438 --- /dev/null +++ b/tests/unit/sdk/test_error_log_spans.py @@ -0,0 +1,176 @@ +"""Tests for error and log span kinds — decorators and standalone functions.""" + +from agentops.sdk.decorators import error as error_decorator, log as log_decorator +from agentops.semconv import SpanKind +from agentops.semconv.span_attributes import SpanAttributes +from tests.unit.sdk.instrumentation_tester import InstrumentationTester + + +class TestErrorDecorator: + """Tests for the @error decorator.""" + + def test_error_decorator_creates_span(self, instrumentation: InstrumentationTester): + @error_decorator + def failing_operation(): + return "error occurred" + + result = failing_operation() + assert result == "error occurred" + + spans = instrumentation.get_finished_spans() + assert len(spans) == 1 + + span = spans[0] + assert span.attributes[SpanAttributes.AGENTOPS_SPAN_KIND] == SpanKind.ERROR + + def test_error_decorator_with_name(self, instrumentation: InstrumentationTester): + @error_decorator(name="custom_error") + def failing_operation(): + return "fail" + + failing_operation() + + spans = instrumentation.get_finished_spans() + assert len(spans) == 1 + assert spans[0].attributes[SpanAttributes.OPERATION_NAME] == "custom_error" + + def test_error_decorator_async(self, instrumentation: InstrumentationTester): + @error_decorator + async def async_failing(): + return "async error" + + import asyncio + + loop = asyncio.new_event_loop() + try: + result = loop.run_until_complete(async_failing()) + finally: + loop.close() + assert result == "async error" + + spans = instrumentation.get_finished_spans() + assert len(spans) == 1 + assert spans[0].attributes[SpanAttributes.AGENTOPS_SPAN_KIND] == SpanKind.ERROR + + +class TestLogDecorator: + """Tests for the @log decorator.""" + + def test_log_decorator_creates_span(self, instrumentation: InstrumentationTester): + @log_decorator + def log_operation(): + return "logged" + + result = log_operation() + assert result == "logged" + + spans = instrumentation.get_finished_spans() + assert len(spans) == 1 + assert spans[0].attributes[SpanAttributes.AGENTOPS_SPAN_KIND] == SpanKind.LOG + + def test_log_decorator_with_name(self, instrumentation: InstrumentationTester): + @log_decorator(name="custom_log") + def log_operation(): + return "log" + + log_operation() + + spans = instrumentation.get_finished_spans() + assert len(spans) == 1 + assert spans[0].attributes[SpanAttributes.OPERATION_NAME] == "custom_log" + + +class TestStandaloneError: + """Tests for the standalone agentops.error() function.""" + + def test_error_creates_span(self, instrumentation: InstrumentationTester): + import agentops + + agentops.error(name="test_error", message="something went wrong") + + spans = instrumentation.get_finished_spans() + assert len(spans) == 1 + + span = spans[0] + assert span.attributes[SpanAttributes.AGENTOPS_SPAN_KIND] == SpanKind.ERROR + assert span.attributes["error.message"] == "something went wrong" + + def test_error_sets_error_status(self, instrumentation: InstrumentationTester): + import agentops + from opentelemetry.trace import StatusCode + + agentops.error(name="status_error", message="bad request") + + spans = instrumentation.get_finished_spans() + assert len(spans) == 1 + assert spans[0].status.status_code == StatusCode.ERROR + + def test_error_with_extra_attributes(self, instrumentation: InstrumentationTester): + import agentops + + agentops.error(name="attr_error", message="fail", code="E001") + + spans = instrumentation.get_finished_spans() + assert len(spans) == 1 + assert spans[0].attributes["code"] == "E001" + + def test_error_default_name(self, instrumentation: InstrumentationTester): + import agentops + + agentops.error(message="default name test") + + spans = instrumentation.get_finished_spans() + assert len(spans) == 1 + assert spans[0].attributes[SpanAttributes.OPERATION_NAME] == "error" + + +class TestStandaloneLog: + """Tests for the standalone agentops.log() function.""" + + def test_log_creates_span(self, instrumentation: InstrumentationTester): + import agentops + + agentops.log(name="test_log", message="hello world") + + spans = instrumentation.get_finished_spans() + assert len(spans) == 1 + + span = spans[0] + assert span.attributes[SpanAttributes.AGENTOPS_SPAN_KIND] == SpanKind.LOG + assert span.attributes["log.message"] == "hello world" + + def test_log_with_level(self, instrumentation: InstrumentationTester): + import agentops + + agentops.log(name="level_log", message="debug info", level="DEBUG") + + spans = instrumentation.get_finished_spans() + assert len(spans) == 1 + assert spans[0].attributes["log.level"] == "DEBUG" + + def test_log_default_level_is_info(self, instrumentation: InstrumentationTester): + import agentops + + agentops.log(name="info_log", message="info message") + + spans = instrumentation.get_finished_spans() + assert len(spans) == 1 + assert spans[0].attributes["log.level"] == "INFO" + + def test_log_with_extra_attributes(self, instrumentation: InstrumentationTester): + import agentops + + agentops.log(name="extra_log", message="data", source="test") + + spans = instrumentation.get_finished_spans() + assert len(spans) == 1 + assert spans[0].attributes["source"] == "test" + + def test_log_default_name(self, instrumentation: InstrumentationTester): + import agentops + + agentops.log(message="default") + + spans = instrumentation.get_finished_spans() + assert len(spans) == 1 + assert spans[0].attributes[SpanAttributes.OPERATION_NAME] == "log"