diff --git a/sdk/ai/azure-ai-projects/azure/ai/projects/telemetry/_ai_project_instrumentor.py b/sdk/ai/azure-ai-projects/azure/ai/projects/telemetry/_ai_project_instrumentor.py index 6dbc434212d4..a55cfa902f82 100644 --- a/sdk/ai/azure-ai-projects/azure/ai/projects/telemetry/_ai_project_instrumentor.py +++ b/sdk/ai/azure-ai-projects/azure/ai/projects/telemetry/_ai_project_instrumentor.py @@ -1283,7 +1283,7 @@ def _agents_apis(self): def _project_apis(self): """Define AIProjectClient APIs to instrument for trace propagation. - + :return: A tuple containing sync and async API tuples. :rtype: Tuple[Tuple, Tuple] """ @@ -1309,7 +1309,7 @@ def _project_apis(self): def _inject_openai_client(self, f, _trace_type, _name): """Injector for get_openai_client that enables trace context propagation if opted in. - + :return: The wrapped function with trace context propagation enabled. :rtype: Callable """ @@ -1331,7 +1331,7 @@ def _agents_api_list(self): def _project_api_list(self): """Generate project API list with custom injector. - + :return: A generator yielding API tuples with injectors. :rtype: Generator """ diff --git a/sdk/ai/azure-ai-projects/azure/ai/projects/telemetry/_trace_function.py b/sdk/ai/azure-ai-projects/azure/ai/projects/telemetry/_trace_function.py index 04a5989795df..956b43792d71 100644 --- a/sdk/ai/azure-ai-projects/azure/ai/projects/telemetry/_trace_function.py +++ b/sdk/ai/azure-ai-projects/azure/ai/projects/telemetry/_trace_function.py @@ -3,14 +3,14 @@ # Licensed under the MIT License. # ------------------------------------ import functools -import asyncio # pylint: disable = do-not-import-asyncio +import inspect from typing import Any, Callable, Optional, Dict try: # pylint: disable = no-name-in-module from opentelemetry import trace as opentelemetry_trace - tracer = opentelemetry_trace.get_tracer(__name__) # type: ignore[attr-defined] + _tracer = opentelemetry_trace.get_tracer(__name__) # type: ignore[attr-defined] _tracing_library_available = True except ModuleNotFoundError: _tracing_library_available = False @@ -50,6 +50,7 @@ async def async_wrapper(*args: Any, **kwargs: Any) -> Any: :return: The result of the decorated asynchronous function. :rtype: Any """ + tracer = opentelemetry_trace.get_tracer(__name__) # type: ignore[attr-defined] name = span_name if span_name else func.__name__ with tracer.start_as_current_span(name) as span: try: @@ -79,6 +80,7 @@ def sync_wrapper(*args: Any, **kwargs: Any) -> Any: :return: The result of the decorated synchronous function. :rtype: Any """ + tracer = opentelemetry_trace.get_tracer(__name__) # type: ignore[attr-defined] name = span_name if span_name else func.__name__ with tracer.start_as_current_span(name) as span: try: @@ -99,7 +101,7 @@ def sync_wrapper(*args: Any, **kwargs: Any) -> Any: raise # Determine if the function is async - if asyncio.iscoroutinefunction(func): + if inspect.iscoroutinefunction(func): return async_wrapper return sync_wrapper @@ -134,15 +136,15 @@ def sanitize_parameters(func, *args, **kwargs) -> Dict[str, Any]: :return: A dictionary of sanitized parameters. :rtype: Dict[str, Any] """ - import inspect - params = inspect.signature(func).parameters sanitized_params = {} for i, (name, param) in enumerate(params.items()): - if param.default == inspect.Parameter.empty and i < len(args): + if i < len(args): + # Use positional argument if provided value = args[i] else: + # Use keyword argument if provided, otherwise fall back to default value value = kwargs.get(name, param.default) sanitized_value = sanitize_for_attributes(value) diff --git a/sdk/ai/azure-ai-projects/tests/agents/telemetry/test_trace_function_decorator.py b/sdk/ai/azure-ai-projects/tests/agents/telemetry/test_trace_function_decorator.py new file mode 100644 index 000000000000..18a96fd8eae3 --- /dev/null +++ b/sdk/ai/azure-ai-projects/tests/agents/telemetry/test_trace_function_decorator.py @@ -0,0 +1,345 @@ +# ------------------------------------ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# ------------------------------------ +"""Tests for the trace_function decorator with synchronous functions.""" +import pytest +from opentelemetry import trace +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.sdk.trace.export import SimpleSpanProcessor +from azure.ai.projects.telemetry._trace_function import trace_function +from gen_ai_trace_verifier import GenAiTraceVerifier +from memory_trace_exporter import MemoryTraceExporter + + +class TestTraceFunctionDecorator: + """Tests for trace_function decorator with synchronous functions.""" + + @pytest.fixture(scope="function") + def setup_telemetry(self): + """Setup telemetry for tests.""" + tracer_provider = TracerProvider() + trace._TRACER_PROVIDER = tracer_provider + self.exporter = MemoryTraceExporter() + span_processor = SimpleSpanProcessor(self.exporter) + tracer_provider.add_span_processor(span_processor) + yield + self.exporter.shutdown() + trace._TRACER_PROVIDER = None + + def test_basic_function_with_primitives(self, setup_telemetry): + """Test decorator with a function that has primitive type parameters and return value.""" + + @trace_function() + def add_numbers(a: int, b: int) -> int: + return a + b + + result = add_numbers(5, 3) + assert result == 8 + + spans = self.exporter.get_spans_by_name("add_numbers") + assert len(spans) == 1 + span = spans[0] + + # Verify parameters are traced + expected_attributes = [ + ("code.function.parameter.a", 5), + ("code.function.parameter.b", 3), + ("code.function.return.value", 8), + ] + attributes_match = GenAiTraceVerifier().check_decorator_span_attributes(span, expected_attributes) + assert attributes_match is True + + def test_function_with_string_parameters(self, setup_telemetry): + """Test decorator with string parameters.""" + + @trace_function() + def greet(name: str, greeting: str = "Hello") -> str: + return f"{greeting}, {name}!" + + result = greet("Alice", "Hi") + assert result == "Hi, Alice!" + + spans = self.exporter.get_spans_by_name("greet") + assert len(spans) == 1 + span = spans[0] + + expected_attributes = [ + ("code.function.parameter.name", "Alice"), + ("code.function.parameter.greeting", "Hi"), + ("code.function.return.value", "Hi, Alice!"), + ] + attributes_match = GenAiTraceVerifier().check_decorator_span_attributes(span, expected_attributes) + assert attributes_match is True + + def test_function_with_list_parameter(self, setup_telemetry): + """Test decorator with list parameters.""" + + @trace_function() + def sum_list(numbers: list) -> int: + return sum(numbers) + + result = sum_list([1, 2, 3, 4, 5]) + assert result == 15 + + spans = self.exporter.get_spans_by_name("sum_list") + assert len(spans) == 1 + span = spans[0] + + expected_attributes = [ + ("code.function.parameter.numbers", [1, 2, 3, 4, 5]), + ("code.function.return.value", 15), + ] + attributes_match = GenAiTraceVerifier().check_decorator_span_attributes(span, expected_attributes) + assert attributes_match is True + + def test_function_with_dict_parameter(self, setup_telemetry): + """Test decorator with dict parameters (converted to string).""" + + @trace_function() + def get_value(data: dict, key: str) -> str: + return data.get(key, "not found") + + result = get_value({"name": "Alice", "age": 30}, "name") + assert result == "Alice" + + spans = self.exporter.get_spans_by_name("get_value") + assert len(spans) == 1 + span = spans[0] + + # Dict parameters are converted to strings + expected_dict_str = str({"name": "Alice", "age": 30}) + expected_attributes = [ + ("code.function.parameter.data", expected_dict_str), + ("code.function.parameter.key", "name"), + ("code.function.return.value", "Alice"), + ] + attributes_match = GenAiTraceVerifier().check_decorator_span_attributes(span, expected_attributes) + assert attributes_match is True + + def test_function_with_nested_collections(self, setup_telemetry): + """Test decorator with nested collections (converted to string).""" + + @trace_function() + def process_nested(data: list) -> int: + return len(data) + + nested_data = [[1, 2], [3, 4], [5, 6]] + result = process_nested(nested_data) + assert result == 3 + + spans = self.exporter.get_spans_by_name("process_nested") + assert len(spans) == 1 + span = spans[0] + + # Nested collections are converted to strings + expected_data_str = str(nested_data) + expected_attributes = [ + ("code.function.parameter.data", expected_data_str), + ("code.function.return.value", 3), + ] + attributes_match = GenAiTraceVerifier().check_decorator_span_attributes(span, expected_attributes) + assert attributes_match is True + + def test_function_with_custom_span_name(self, setup_telemetry): + """Test decorator with custom span name.""" + + @trace_function(span_name="custom_operation") + def calculate(x: int, y: int) -> int: + return x * y + + result = calculate(4, 7) + assert result == 28 + + spans = self.exporter.get_spans_by_name("custom_operation") + assert len(spans) == 1 + span = spans[0] + + expected_attributes = [ + ("code.function.parameter.x", 4), + ("code.function.parameter.y", 7), + ("code.function.return.value", 28), + ] + attributes_match = GenAiTraceVerifier().check_decorator_span_attributes(span, expected_attributes) + assert attributes_match is True + + def test_function_with_no_return_value(self, setup_telemetry): + """Test decorator with a function that returns None.""" + + @trace_function() + def log_message(message: str) -> None: + pass # Just a placeholder + + result = log_message("Test message") + assert result is None + + spans = self.exporter.get_spans_by_name("log_message") + assert len(spans) == 1 + span = spans[0] + + # Only parameter should be traced, no return value attribute + expected_attributes = [ + ("code.function.parameter.message", "Test message"), + ] + attributes_match = GenAiTraceVerifier().check_decorator_span_attributes(span, expected_attributes) + assert attributes_match is True + + def test_function_with_boolean_parameters(self, setup_telemetry): + """Test decorator with boolean parameters.""" + + @trace_function() + def check_status(is_active: bool, is_verified: bool) -> str: + if is_active and is_verified: + return "approved" + return "pending" + + result = check_status(True, False) + assert result == "pending" + + spans = self.exporter.get_spans_by_name("check_status") + assert len(spans) == 1 + span = spans[0] + + expected_attributes = [ + ("code.function.parameter.is_active", True), + ("code.function.parameter.is_verified", False), + ("code.function.return.value", "pending"), + ] + attributes_match = GenAiTraceVerifier().check_decorator_span_attributes(span, expected_attributes) + assert attributes_match is True + + def test_function_with_float_parameters(self, setup_telemetry): + """Test decorator with float parameters.""" + + @trace_function() + def calculate_average(a: float, b: float, c: float) -> float: + return (a + b + c) / 3 + + result = calculate_average(10.5, 20.3, 15.2) + assert abs(result - 15.333333333333334) < 0.0001 + + spans = self.exporter.get_spans_by_name("calculate_average") + assert len(spans) == 1 + span = spans[0] + + expected_attributes = [ + ("code.function.parameter.a", 10.5), + ("code.function.parameter.b", 20.3), + ("code.function.parameter.c", 15.2), + ("code.function.return.value", result), + ] + attributes_match = GenAiTraceVerifier().check_decorator_span_attributes(span, expected_attributes) + assert attributes_match is True + + def test_function_with_tuple_parameter(self, setup_telemetry): + """Test decorator with tuple parameters.""" + + @trace_function() + def get_coordinates(point: tuple) -> str: + return f"x={point[0]}, y={point[1]}" + + result = get_coordinates((10, 20)) + assert result == "x=10, y=20" + + spans = self.exporter.get_spans_by_name("get_coordinates") + assert len(spans) == 1 + span = spans[0] + + expected_attributes = [ + ("code.function.parameter.point", (10, 20)), + ("code.function.return.value", "x=10, y=20"), + ] + attributes_match = GenAiTraceVerifier().check_decorator_span_attributes(span, expected_attributes) + assert attributes_match is True + + def test_function_with_set_parameter(self, setup_telemetry): + """Test decorator with set parameters (converted to string).""" + + @trace_function() + def count_unique(items: set) -> int: + return len(items) + + result = count_unique({1, 2, 3, 4, 5}) + assert result == 5 + + spans = self.exporter.get_spans_by_name("count_unique") + assert len(spans) == 1 + span = spans[0] + + # Sets are converted to strings, but we need to compare the actual set values + # since set string representation order is non-deterministic + assert span.attributes is not None + assert "code.function.parameter.items" in span.attributes + assert "code.function.return.value" in span.attributes + + # Convert the string back to a set for comparison + import ast + + items_str = span.attributes["code.function.parameter.items"] + assert isinstance(items_str, str) + items_value = ast.literal_eval(items_str) + assert items_value == {1, 2, 3, 4, 5} + assert span.attributes["code.function.return.value"] == 5 + + def test_function_with_exception(self, setup_telemetry): + """Test decorator records exception information.""" + + @trace_function() + def divide(a: int, b: int) -> float: + return a / b + + with pytest.raises(ZeroDivisionError): + divide(10, 0) + + spans = self.exporter.get_spans_by_name("divide") + assert len(spans) == 1 + span = spans[0] + + # Check that parameters were traced + expected_attributes = [ + ("code.function.parameter.a", 10), + ("code.function.parameter.b", 0), + ("error.type", "ZeroDivisionError"), + ] + attributes_match = GenAiTraceVerifier().check_decorator_span_attributes(span, expected_attributes) + assert attributes_match is True + + # Verify exception was recorded as an event + assert len(span.events) > 0 + exception_event = None + for event in span.events: + if event.name == "exception": + exception_event = event + break + assert exception_event is not None + + def test_function_with_mixed_parameters(self, setup_telemetry): + """Test decorator with mixed parameter types.""" + + @trace_function() + def process_data(name: str, count: int, active: bool, scores: list) -> dict: + return { + "name": name, + "count": count, + "active": active, + "average": sum(scores) / len(scores) if scores else 0, + } + + result = process_data("test", 5, True, [90, 85, 95]) + assert result["name"] == "test" + assert result["average"] == 90 + + spans = self.exporter.get_spans_by_name("process_data") + assert len(spans) == 1 + span = spans[0] + + expected_result_str = str(result) + expected_attributes = [ + ("code.function.parameter.name", "test"), + ("code.function.parameter.count", 5), + ("code.function.parameter.active", True), + ("code.function.parameter.scores", [90, 85, 95]), + ("code.function.return.value", expected_result_str), + ] + attributes_match = GenAiTraceVerifier().check_decorator_span_attributes(span, expected_attributes) + assert attributes_match is True diff --git a/sdk/ai/azure-ai-projects/tests/agents/telemetry/test_trace_function_decorator_async.py b/sdk/ai/azure-ai-projects/tests/agents/telemetry/test_trace_function_decorator_async.py new file mode 100644 index 000000000000..dd7d92bed63c --- /dev/null +++ b/sdk/ai/azure-ai-projects/tests/agents/telemetry/test_trace_function_decorator_async.py @@ -0,0 +1,408 @@ +# ------------------------------------ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# ------------------------------------ +"""Tests for the trace_function decorator with asynchronous functions.""" +import pytest +from opentelemetry import trace +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.sdk.trace.export import SimpleSpanProcessor +from azure.ai.projects.telemetry._trace_function import trace_function +from gen_ai_trace_verifier import GenAiTraceVerifier +from memory_trace_exporter import MemoryTraceExporter + + +class TestTraceFunctionDecoratorAsync: + """Tests for trace_function decorator with asynchronous functions.""" + + @pytest.fixture(scope="function") + def setup_telemetry(self): + """Setup telemetry for tests.""" + tracer_provider = TracerProvider() + trace._TRACER_PROVIDER = tracer_provider + self.exporter = MemoryTraceExporter() + span_processor = SimpleSpanProcessor(self.exporter) + tracer_provider.add_span_processor(span_processor) + yield + self.exporter.shutdown() + trace._TRACER_PROVIDER = None + + @pytest.mark.asyncio + async def test_async_basic_function_with_primitives(self, setup_telemetry): + """Test decorator with an async function that has primitive type parameters.""" + + @trace_function() + async def add_numbers_async(a: int, b: int) -> int: + return a + b + + result = await add_numbers_async(5, 3) + assert result == 8 + + spans = self.exporter.get_spans_by_name("add_numbers_async") + assert len(spans) == 1 + span = spans[0] + + expected_attributes = [ + ("code.function.parameter.a", 5), + ("code.function.parameter.b", 3), + ("code.function.return.value", 8), + ] + attributes_match = GenAiTraceVerifier().check_decorator_span_attributes(span, expected_attributes) + assert attributes_match is True + + @pytest.mark.asyncio + async def test_async_function_with_string_parameters(self, setup_telemetry): + """Test decorator with async function and string parameters.""" + + @trace_function() + async def greet_async(name: str, greeting: str = "Hello") -> str: + return f"{greeting}, {name}!" + + result = await greet_async("Bob", "Good morning") + assert result == "Good morning, Bob!" + + spans = self.exporter.get_spans_by_name("greet_async") + assert len(spans) == 1 + span = spans[0] + + expected_attributes = [ + ("code.function.parameter.name", "Bob"), + ("code.function.parameter.greeting", "Good morning"), + ("code.function.return.value", "Good morning, Bob!"), + ] + attributes_match = GenAiTraceVerifier().check_decorator_span_attributes(span, expected_attributes) + assert attributes_match is True + + @pytest.mark.asyncio + async def test_async_function_with_list_parameter(self, setup_telemetry): + """Test decorator with async function and list parameters.""" + + @trace_function() + async def sum_list_async(numbers: list) -> int: + return sum(numbers) + + result = await sum_list_async([10, 20, 30, 40]) + assert result == 100 + + spans = self.exporter.get_spans_by_name("sum_list_async") + assert len(spans) == 1 + span = spans[0] + + expected_attributes = [ + ("code.function.parameter.numbers", [10, 20, 30, 40]), + ("code.function.return.value", 100), + ] + attributes_match = GenAiTraceVerifier().check_decorator_span_attributes(span, expected_attributes) + assert attributes_match is True + + @pytest.mark.asyncio + async def test_async_function_with_dict_parameter(self, setup_telemetry): + """Test decorator with async function and dict parameters.""" + + @trace_function() + async def get_value_async(data: dict, key: str) -> str: + return data.get(key, "not found") + + result = await get_value_async({"city": "Seattle", "state": "WA"}, "city") + assert result == "Seattle" + + spans = self.exporter.get_spans_by_name("get_value_async") + assert len(spans) == 1 + span = spans[0] + + expected_dict_str = str({"city": "Seattle", "state": "WA"}) + expected_attributes = [ + ("code.function.parameter.data", expected_dict_str), + ("code.function.parameter.key", "city"), + ("code.function.return.value", "Seattle"), + ] + attributes_match = GenAiTraceVerifier().check_decorator_span_attributes(span, expected_attributes) + assert attributes_match is True + + @pytest.mark.asyncio + async def test_async_function_with_nested_collections(self, setup_telemetry): + """Test decorator with async function and nested collections.""" + + @trace_function() + async def process_nested_async(data: list) -> int: + total = 0 + for sublist in data: + total += sum(sublist) + return total + + nested_data = [[1, 2, 3], [4, 5, 6], [7, 8, 9]] + result = await process_nested_async(nested_data) + assert result == 45 + + spans = self.exporter.get_spans_by_name("process_nested_async") + assert len(spans) == 1 + span = spans[0] + + expected_data_str = str(nested_data) + expected_attributes = [ + ("code.function.parameter.data", expected_data_str), + ("code.function.return.value", 45), + ] + attributes_match = GenAiTraceVerifier().check_decorator_span_attributes(span, expected_attributes) + assert attributes_match is True + + @pytest.mark.asyncio + async def test_async_function_with_custom_span_name(self, setup_telemetry): + """Test decorator with custom span name on async function.""" + + @trace_function(span_name="async_custom_operation") + async def calculate_async(x: int, y: int) -> int: + return x * y + + result = await calculate_async(6, 9) + assert result == 54 + + spans = self.exporter.get_spans_by_name("async_custom_operation") + assert len(spans) == 1 + span = spans[0] + + expected_attributes = [ + ("code.function.parameter.x", 6), + ("code.function.parameter.y", 9), + ("code.function.return.value", 54), + ] + attributes_match = GenAiTraceVerifier().check_decorator_span_attributes(span, expected_attributes) + assert attributes_match is True + + @pytest.mark.asyncio + async def test_async_function_with_no_return_value(self, setup_telemetry): + """Test decorator with async function that returns None.""" + + @trace_function() + async def log_message_async(message: str) -> None: + pass # Just a placeholder + + result = await log_message_async("Async test message") + assert result is None + + spans = self.exporter.get_spans_by_name("log_message_async") + assert len(spans) == 1 + span = spans[0] + + expected_attributes = [ + ("code.function.parameter.message", "Async test message"), + ] + attributes_match = GenAiTraceVerifier().check_decorator_span_attributes(span, expected_attributes) + assert attributes_match is True + + @pytest.mark.asyncio + async def test_async_function_with_boolean_parameters(self, setup_telemetry): + """Test decorator with async function and boolean parameters.""" + + @trace_function() + async def check_status_async(is_active: bool, is_verified: bool) -> str: + if is_active and is_verified: + return "approved" + elif is_active: + return "pending" + return "inactive" + + result = await check_status_async(True, True) + assert result == "approved" + + spans = self.exporter.get_spans_by_name("check_status_async") + assert len(spans) == 1 + span = spans[0] + + expected_attributes = [ + ("code.function.parameter.is_active", True), + ("code.function.parameter.is_verified", True), + ("code.function.return.value", "approved"), + ] + attributes_match = GenAiTraceVerifier().check_decorator_span_attributes(span, expected_attributes) + assert attributes_match is True + + @pytest.mark.asyncio + async def test_async_function_with_float_parameters(self, setup_telemetry): + """Test decorator with async function and float parameters.""" + + @trace_function() + async def calculate_area_async(width: float, height: float) -> float: + return width * height + + result = await calculate_area_async(12.5, 8.3) + assert abs(result - 103.75) < 0.01 + + spans = self.exporter.get_spans_by_name("calculate_area_async") + assert len(spans) == 1 + span = spans[0] + + expected_attributes = [ + ("code.function.parameter.width", 12.5), + ("code.function.parameter.height", 8.3), + ("code.function.return.value", result), + ] + attributes_match = GenAiTraceVerifier().check_decorator_span_attributes(span, expected_attributes) + assert attributes_match is True + + @pytest.mark.asyncio + async def test_async_function_with_tuple_parameter(self, setup_telemetry): + """Test decorator with async function and tuple parameters.""" + + @trace_function() + async def get_coordinates_async(point: tuple) -> str: + return f"Position: ({point[0]}, {point[1]}, {point[2]})" + + result = await get_coordinates_async((10, 20, 30)) + assert result == "Position: (10, 20, 30)" + + spans = self.exporter.get_spans_by_name("get_coordinates_async") + assert len(spans) == 1 + span = spans[0] + + expected_attributes = [ + ("code.function.parameter.point", (10, 20, 30)), + ("code.function.return.value", "Position: (10, 20, 30)"), + ] + attributes_match = GenAiTraceVerifier().check_decorator_span_attributes(span, expected_attributes) + assert attributes_match is True + + @pytest.mark.asyncio + async def test_async_function_with_set_parameter(self, setup_telemetry): + """Test decorator with async function and set parameters.""" + + @trace_function() + async def count_unique_async(items: set) -> int: + return len(items) + + result = await count_unique_async({10, 20, 30, 40, 50}) + assert result == 5 + + spans = self.exporter.get_spans_by_name("count_unique_async") + assert len(spans) == 1 + span = spans[0] + + # Sets are converted to strings, but we need to compare the actual set values + # since set string representation order is non-deterministic + assert span.attributes is not None + assert "code.function.parameter.items" in span.attributes + assert "code.function.return.value" in span.attributes + + # Convert the string back to a set for comparison + import ast + + items_str = span.attributes["code.function.parameter.items"] + assert isinstance(items_str, str) + items_value = ast.literal_eval(items_str) + assert items_value == {10, 20, 30, 40, 50} + assert span.attributes["code.function.return.value"] == 5 + + @pytest.mark.asyncio + async def test_async_function_with_exception(self, setup_telemetry): + """Test decorator records exception information in async functions.""" + + @trace_function() + async def divide_async(a: int, b: int) -> float: + return a / b + + with pytest.raises(ZeroDivisionError): + await divide_async(100, 0) + + spans = self.exporter.get_spans_by_name("divide_async") + assert len(spans) == 1 + span = spans[0] + + expected_attributes = [ + ("code.function.parameter.a", 100), + ("code.function.parameter.b", 0), + ("error.type", "ZeroDivisionError"), + ] + attributes_match = GenAiTraceVerifier().check_decorator_span_attributes(span, expected_attributes) + assert attributes_match is True + + # Verify exception was recorded as an event + assert len(span.events) > 0 + exception_event = None + for event in span.events: + if event.name == "exception": + exception_event = event + break + assert exception_event is not None + + @pytest.mark.asyncio + async def test_async_function_with_mixed_parameters(self, setup_telemetry): + """Test decorator with async function and mixed parameter types.""" + + @trace_function() + async def process_data_async(name: str, count: int, active: bool, scores: list) -> dict: + return { + "name": name.upper(), + "count": count * 2, + "active": active, + "total": sum(scores), + } + + result = await process_data_async("async_test", 3, False, [100, 200, 300]) + assert result["name"] == "ASYNC_TEST" + assert result["count"] == 6 + assert result["total"] == 600 + + spans = self.exporter.get_spans_by_name("process_data_async") + assert len(spans) == 1 + span = spans[0] + + expected_result_str = str(result) + expected_attributes = [ + ("code.function.parameter.name", "async_test"), + ("code.function.parameter.count", 3), + ("code.function.parameter.active", False), + ("code.function.parameter.scores", [100, 200, 300]), + ("code.function.return.value", expected_result_str), + ] + attributes_match = GenAiTraceVerifier().check_decorator_span_attributes(span, expected_attributes) + assert attributes_match is True + + @pytest.mark.asyncio + async def test_async_function_with_default_parameters(self, setup_telemetry): + """Test decorator with async function using default parameters.""" + + @trace_function() + async def create_user_async(name: str, role: str = "user", active: bool = True) -> dict: + return {"name": name, "role": role, "active": active} + + result = await create_user_async("Charlie") + assert result["name"] == "Charlie" + assert result["role"] == "user" + assert result["active"] is True + + spans = self.exporter.get_spans_by_name("create_user_async") + assert len(spans) == 1 + span = spans[0] + + expected_result_str = str(result) + expected_attributes = [ + ("code.function.parameter.name", "Charlie"), + ("code.function.parameter.role", "user"), + ("code.function.parameter.active", True), + ("code.function.return.value", expected_result_str), + ] + attributes_match = GenAiTraceVerifier().check_decorator_span_attributes(span, expected_attributes) + assert attributes_match is True + + @pytest.mark.asyncio + async def test_async_function_list_return_value(self, setup_telemetry): + """Test decorator with async function returning a list.""" + + @trace_function() + async def get_range_async(start: int, end: int) -> list: + return list(range(start, end)) + + result = await get_range_async(1, 6) + assert result == [1, 2, 3, 4, 5] + + spans = self.exporter.get_spans_by_name("get_range_async") + assert len(spans) == 1 + span = spans[0] + + expected_attributes = [ + ("code.function.parameter.start", 1), + ("code.function.parameter.end", 6), + ("code.function.return.value", [1, 2, 3, 4, 5]), + ] + attributes_match = GenAiTraceVerifier().check_decorator_span_attributes(span, expected_attributes) + assert attributes_match is True