diff --git a/agentops/__init__.py b/agentops/__init__.py index 148dcc375..4e81c5d6f 100755 --- a/agentops/__init__.py +++ b/agentops/__init__.py @@ -124,6 +124,7 @@ def configure(**kwargs): _client.configure(**kwargs) + # For backwards compatibility and testing @@ -132,8 +133,7 @@ def get_client() -> Client: return _client - -from agentops.legacy import * # type: ignore +from agentops.legacy import * # type: ignore __all__ = [ "init", diff --git a/agentops/helpers/validation.py b/agentops/helpers/validation.py index 2a0c219cf..78c5d2008 100644 --- a/agentops/helpers/validation.py +++ b/agentops/helpers/validation.py @@ -4,4 +4,5 @@ def is_coroutine_or_generator(fn: Any) -> bool: """Check if a function is asynchronous (coroutine or async generator)""" import inspect + return inspect.iscoroutinefunction(fn) or inspect.isasyncgenfunction(fn) diff --git a/agentops/legacy/__init__.py b/agentops/legacy/__init__.py index 56ea94434..551afa907 100644 --- a/agentops/legacy/__init__.py +++ b/agentops/legacy/__init__.py @@ -39,7 +39,6 @@ def end_session(self): self.span.end() - def start_session( tags: Union[Dict[str, Any], List[str], None] = None, ) -> Session: @@ -60,15 +59,15 @@ def start_session( Returns: A Session object that should be passed to end_session """ - from agentops import Client if not Client().initialized: Client().init() from agentops.sdk.decorators.utility import _make_span + attributes = {} if tags: attributes["tags"] = tags - span, context, token = _make_span('session', span_kind=SpanKind.SESSION, attributes=attributes) + span, context, token = _make_span("session", span_kind=SpanKind.SESSION, attributes=attributes) return Session(span, token) @@ -85,6 +84,7 @@ def end_session(session: Session) -> None: session: The session object returned by start_session """ from agentops.sdk.decorators.utility import _finalize_span + _finalize_span(session.span, session.token) diff --git a/agentops/sdk/__init__.py b/agentops/sdk/__init__.py index 1b0779dd5..f1be1f718 100644 --- a/agentops/sdk/__init__.py +++ b/agentops/sdk/__init__.py @@ -7,8 +7,10 @@ # Import core components from agentops.sdk.core import TracingCore + # Import decorators from agentops.sdk.decorators import agent, operation, session, task, workflow + # from agentops.sdk.traced import TracedObject # Merged into TracedObject from agentops.sdk.types import TracingConfig diff --git a/agentops/sdk/decorators/__init__.py b/agentops/sdk/decorators/__init__.py index a7ffad8a9..706bd4624 100644 --- a/agentops/sdk/decorators/__init__.py +++ b/agentops/sdk/decorators/__init__.py @@ -16,12 +16,6 @@ session = create_entity_decorator(SpanKind.SESSION) operation = task -__all__ = [ - 'agent', - 'task', - 'workflow', - 'session', - 'operation' -] +__all__ = ["agent", "task", "workflow", "session", "operation"] # Create decorators task, workflow, session, agent diff --git a/agentops/sdk/decorators/factory.py b/agentops/sdk/decorators/factory.py index b29ade4d6..1852ed314 100644 --- a/agentops/sdk/decorators/factory.py +++ b/agentops/sdk/decorators/factory.py @@ -8,26 +8,33 @@ from agentops.logging import logger from agentops.sdk.core import TracingCore -from .utility import (_create_as_current_span, _finalize_span, _make_span, - _process_async_generator, _process_sync_generator, - _record_entity_input, _record_entity_output) +from .utility import ( + _create_as_current_span, + _finalize_span, + _make_span, + _process_async_generator, + _process_sync_generator, + _record_entity_input, + _record_entity_output, +) def create_entity_decorator(entity_kind: str): """ Factory function that creates decorators for specific entity kinds. - + Args: entity_kind: The type of operation being performed (SpanKind.*) - + Returns: A decorator with optional arguments for name and version """ + def decorator(wrapped=None, *, name=None, version=None): # Handle case where decorator is called with parameters if wrapped is None: return functools.partial(decorator, name=name, version=version) - + # Handle class decoration if inspect.isclass(wrapped): # Create a proxy class that wraps the original class @@ -37,33 +44,33 @@ def __init__(self, *args, **kwargs): operation_name = name or wrapped.__name__ self._agentops_span_context_manager = _create_as_current_span(operation_name, entity_kind, version) self._agentops_active_span = self._agentops_span_context_manager.__enter__() - + try: _record_entity_input(self._agentops_active_span, args, kwargs) except Exception as e: logger.warning(f"Failed to record entity input: {e}") - + # Call the original __init__ super().__init__(*args, **kwargs) - + def __del__(self): # End span when instance is destroyed - if hasattr(self, '_agentops_active_span') and hasattr(self, '_agentops_span_context_manager'): + if hasattr(self, "_agentops_active_span") and hasattr(self, "_agentops_span_context_manager"): try: _record_entity_output(self._agentops_active_span, self) except Exception as e: logger.warning(f"Failed to record entity output: {e}") - + self._agentops_span_context_manager.__exit__(None, None, None) - + # Preserve metadata of the original class WrappedClass.__name__ = wrapped.__name__ WrappedClass.__qualname__ = wrapped.__qualname__ WrappedClass.__module__ = wrapped.__module__ WrappedClass.__doc__ = wrapped.__doc__ - + return WrappedClass - + # Create the actual decorator wrapper function for functions @wrapt.decorator def wrapper(wrapped, instance, args, kwargs): @@ -87,10 +94,10 @@ def wrapper(wrapped, instance, args, kwargs): _record_entity_input(span, args, kwargs) except Exception as e: logger.warning(f"Failed to record entity input: {e}") - + result = wrapped(*args, **kwargs) return _process_sync_generator(span, result) - + # Handle async generator functions elif is_async_generator: # Use the old approach for async generators @@ -99,19 +106,20 @@ def wrapper(wrapped, instance, args, kwargs): _record_entity_input(span, args, kwargs) except Exception as e: logger.warning(f"Failed to record entity input: {e}") - + result = wrapped(*args, **kwargs) return _process_async_generator(span, token, result) - + # Handle async functions elif is_async: + async def _wrapped_async(): with _create_as_current_span(operation_name, entity_kind, version) as span: try: _record_entity_input(span, args, kwargs) except Exception as e: logger.warning(f"Failed to record entity input: {e}") - + try: result = await wrapped(*args, **kwargs) try: @@ -122,9 +130,9 @@ async def _wrapped_async(): except Exception as e: span.record_exception(e) raise - + return _wrapped_async() - + # Handle sync functions else: with _create_as_current_span(operation_name, entity_kind, version) as span: @@ -132,7 +140,7 @@ async def _wrapped_async(): _record_entity_input(span, args, kwargs) except Exception as e: logger.warning(f"Failed to record entity input: {e}") - + try: result = wrapped(*args, **kwargs) try: @@ -145,8 +153,6 @@ async def _wrapped_async(): raise # Return the wrapper for functions, we already returned WrappedClass for classes - return wrapper(wrapped) # type: ignore - - return decorator - + return wrapper(wrapped) # type: ignore + return decorator diff --git a/agentops/sdk/decorators/utility.py b/agentops/sdk/decorators/utility.py index a55c54056..66e304c60 100644 --- a/agentops/sdk/decorators/utility.py +++ b/agentops/sdk/decorators/utility.py @@ -31,6 +31,7 @@ def set_workflow_name(workflow_name: str) -> None: def set_entity_path(entity_path: str) -> None: attach(set_value("entity_path", entity_path)) + # Helper functions for content management @@ -73,17 +74,14 @@ def _get_current_span_info(): "span_id": f"{ctx.span_id:x}" if hasattr(ctx, "span_id") else "None", "trace_id": f"{ctx.trace_id:x}" if hasattr(ctx, "trace_id") else "None", "name": getattr(current_span, "name", "Unknown"), - "is_recording": getattr(current_span, "is_recording", False) + "is_recording": getattr(current_span, "is_recording", False), } return {"name": "No current span"} @contextmanager def _create_as_current_span( - operation_name: str, - span_kind: str, - version: Optional[int] = None, - attributes: Optional[Dict[str, Any]] = None + operation_name: str, span_kind: str, version: Optional[int] = None, attributes: Optional[Dict[str, Any]] = None ) -> Generator[Span, None, None]: """ Create and yield an instrumentation span as the current span using proper context management. @@ -104,7 +102,7 @@ def _create_as_current_span( # Log before we do anything before_span = _get_current_span_info() logger.debug(f"[DEBUG] BEFORE {operation_name}.{span_kind} - Current context: {before_span}") - + # Create span with proper naming convention span_name = f"{operation_name}.{span_kind}" @@ -125,26 +123,25 @@ def _create_as_current_span( # Get current context explicitly to debug it current_context = context_api.get_current() - + # Use OpenTelemetry's context manager to properly handle span lifecycle with tracer.start_as_current_span(span_name, attributes=attributes, context=current_context) as span: # Log after span creation if hasattr(span, "get_span_context"): span_ctx = span.get_span_context() - logger.debug(f"[DEBUG] CREATED {span_name} - span_id: {span_ctx.span_id:x}, parent: {before_span.get('span_id', 'None')}") - + logger.debug( + f"[DEBUG] CREATED {span_name} - span_id: {span_ctx.span_id:x}, parent: {before_span.get('span_id', 'None')}" + ) + yield span - + # Log after we're done after_span = _get_current_span_info() logger.debug(f"[DEBUG] AFTER {operation_name}.{span_kind} - Returned to context: {after_span}") def _make_span( - operation_name: str, - span_kind: str, - version: Optional[int] = None, - attributes: Optional[Dict[str, Any]] = None + operation_name: str, span_kind: str, version: Optional[int] = None, attributes: Optional[Dict[str, Any]] = None ) -> tuple: """ Create a span without context management for manual span lifecycle control. @@ -167,7 +164,7 @@ def _make_span( # Log before we do anything before_span = _get_current_span_info() logger.debug(f"[DEBUG] BEFORE _make_span {operation_name}.{span_kind} - Current context: {before_span}") - + # Create span with proper naming convention span_name = f"{operation_name}.{span_kind}" @@ -188,18 +185,20 @@ def _make_span( # Get current context explicitly current_context = context_api.get_current() - + # Create the span with explicit context span = tracer.start_span(span_name, context=current_context, attributes=attributes) # Set as current context and get token for later detachment ctx = trace.set_span_in_context(span) token = context_api.attach(ctx) - + # Log after span creation if hasattr(span, "get_span_context"): span_ctx = span.get_span_context() - logger.debug(f"[DEBUG] CREATED _make_span {span_name} - span_id: {span_ctx.span_id:x}, parent: {before_span.get('span_id', 'None')}") + logger.debug( + f"[DEBUG] CREATED _make_span {span_name} - span_id: {span_ctx.span_id:x}, parent: {before_span.get('span_id', 'None')}" + ) return span, ctx, token @@ -236,15 +235,15 @@ def _finalize_span(span: trace.Span, token: Any) -> None: if hasattr(span, "get_span_context") and hasattr(span.get_span_context(), "span_id"): span_id = f"{span.get_span_context().span_id:x}" logger.debug(f"[DEBUG] ENDING span {getattr(span, 'name', 'unknown')} - span_id: {span_id}") - + span.end() - + # Debug info before detaching current_after_end = _get_current_span_info() logger.debug(f"[DEBUG] AFTER span.end() - Current context: {current_after_end}") - + context_api.detach(token) - + # Debug info after detaching final_context = _get_current_span_info() logger.debug(f"[DEBUG] AFTER detach - Final context: {final_context}") diff --git a/agentops/semconv/span_kinds.py b/agentops/semconv/span_kinds.py index 190afacbe..3315e04b4 100644 --- a/agentops/semconv/span_kinds.py +++ b/agentops/semconv/span_kinds.py @@ -16,7 +16,7 @@ class SpanKind: # Workflow kinds WORKFLOW_STEP = "workflow.step" # Step in a workflow - WORKFLOW = 'workflow' + WORKFLOW = "workflow" SESSION = "session" TASK = "task" OPERATION = "operation" diff --git a/examples/opentelemetry/token_importance.py b/examples/opentelemetry/token_importance.py index fd3c71159..654e5aa64 100644 --- a/examples/opentelemetry/token_importance.py +++ b/examples/opentelemetry/token_importance.py @@ -7,18 +7,20 @@ import json from typing import Dict, Any, List, Optional, Sequence + # Create a no-op exporter to prevent spans from being printed class NoopExporter(SpanExporter): """A span exporter that doesn't export spans anywhere.""" - + def export(self, spans: Sequence) -> None: """Do nothing with the spans.""" pass - + def shutdown(self) -> None: """Shutdown the exporter.""" pass + # Set up basic tracing provider = TracerProvider() # Use the NoopExporter instead of ConsoleSpanExporter @@ -32,16 +34,19 @@ def shutdown(self) -> None: # ======== Visualization Helpers ======== + def print_header(title): """Print a formatted header""" print("\n" + "=" * 80) print(f" {title}") print("=" * 80) + def print_step(step_num, description): """Print a step in the process""" print(f"\n[Step {step_num}] {description}") + def print_span_tree(spans, indent=0): """Print a visual representation of the span tree""" for i, span in enumerate(spans): @@ -49,12 +54,13 @@ def print_span_tree(spans, indent=0): prefix = "└── " if is_last else "├── " print("│ " * indent + prefix + span) + def print_context_state(active_span_name, context_stack=None, baggage_items=None): """Print the current context state with visualization""" print("\n Current Context State:") print(" --------------------") print(f" Active span: {active_span_name}") - + if context_stack: print("\n Context Stack (top to bottom):") for i, span in enumerate(context_stack): @@ -63,26 +69,27 @@ def print_context_state(active_span_name, context_stack=None, baggage_items=None else: print(f" │ {span}") print(" └─────────────") - + if baggage_items: print("\n Baggage Items:") print(" -------------") for key, value in baggage_items.items(): print(f" 🔷 {key}: {value}") + def print_span_details(span, title="Span Details"): """Print detailed information about a span""" if not hasattr(span, "get_span_context"): print(" No span details available") return - + ctx = span.get_span_context() print(f"\n {title}:") print(" " + "-" * len(title)) print(f" Name: {getattr(span, 'name', 'Unknown')}") print(f" Trace ID: {ctx.trace_id:x}") print(f" Span ID: {ctx.span_id:x}") - + # Try to get attributes if possible attributes = getattr(span, "_attributes", {}) if attributes: @@ -90,11 +97,13 @@ def print_span_details(span, title="Span Details"): for key, value in attributes.items(): print(f" 📎 {key}: {str(value)}") + def get_current_span_name(): """Get the name of the current span or 'None' if no span is active""" current = trace.get_current_span() return getattr(current, "name", "None") + def get_current_baggage() -> Dict[str, str]: """Get all baggage items in the current context""" items = {} @@ -105,80 +114,82 @@ def get_current_baggage() -> Dict[str, str]: items[key] = value return items + # ======== Simulated Application Functions ======== + def simulate_database_query(query: str) -> Dict[str, Any]: """Simulate a database query with proper context propagation""" with tracer.start_as_current_span("database.query") as span: span.set_attribute("db.statement", query) span.set_attribute("db.system", "postgresql") - + # Simulate query execution time time.sleep(0.01) - + # Add current baggage to demonstrate propagation user_id = baggage.get_baggage("user.id") if user_id: span.set_attribute("user.id", str(user_id)) - + # Return simulated data return {"id": 1234, "name": "Sample Data", "status": "active"} + def call_external_api(endpoint: str) -> Dict[str, Any]: """Simulate an external API call with a different tracer""" with llm_tracer.start_as_current_span("http.request") as span: span.set_attribute("http.url", f"https://api.example.com/{endpoint}") span.set_attribute("http.method", "GET") - + # Simulate API call latency time.sleep(0.02) - + # Add baggage to simulate cross-service propagation tenant_id = baggage.get_baggage("tenant.id") if tenant_id: span.set_attribute("tenant.id", str(tenant_id)) - + # Sometimes operations fail if endpoint == "error": span.set_status(Status(StatusCode.ERROR)) span.set_attribute("error.message", "API returned 500 status code") return {"error": "Internal Server Error"} - + return {"status": "success", "data": {"key": "value"}} + def process_user_request(user_id: str, action: str) -> Dict[str, Any]: """Process a user request with nested spans and context propagation""" # Set baggage for the entire operation ctx = baggage.set_baggage("user.id", user_id) ctx = baggage.set_baggage("tenant.id", "tenant-1234", context=ctx) ctx = baggage.set_baggage("request.id", f"req-{int(time.time())}", context=ctx) - + # Attach the context with baggage token = context.attach(ctx) - + try: with tracer.start_as_current_span("process_request") as span: span.set_attribute("user.id", user_id) span.set_attribute("request.action", action) - + # Query the database (creates a child span) db_result = simulate_database_query(f"SELECT * FROM users WHERE id = '{user_id}'") - + # Call an external API (creates a child span with a different tracer) api_result = call_external_api("users/profile") - + # Combine results - return { - "user": db_result, - "profile": api_result, - "processed_at": time.time() - } + return {"user": db_result, "profile": api_result, "processed_at": time.time()} finally: # Always detach the context to clean up context.detach(token) + # ======== Scenarios ======== + def run_basic_scenarios(): """Run the original basic scenarios to demonstrate token importance""" # Scenario 1: Proper token management @@ -191,26 +202,26 @@ def run_basic_scenarios(): parent_name = get_current_span_name() print_context_state(parent_name, ["parent"]) print_span_tree(["parent"]) - + print_step(2, "Creating child span and attaching to context") # Manually create a child span and save the token child = tracer.start_span("child") ctx = trace.set_span_in_context(child) token = context.attach(ctx) - + child_name = get_current_span_name() print_context_state(child_name, ["child", "parent"]) print_span_tree(["parent", "child"]) - + print_step(3, "Ending child span AND detaching token (proper cleanup)") # End the child span and detach the token child.end() context.detach(token) - + restored_name = get_current_span_name() print_context_state(restored_name, ["parent"]) print_span_tree(["parent"]) - + print("\n✅ Result: Context properly restored to parent after child span ended") # Scenario 2: Missing token detachment @@ -223,26 +234,26 @@ def run_basic_scenarios(): parent_name = get_current_span_name() print_context_state(parent_name, ["parent2"]) print_span_tree(["parent2"]) - + print_step(2, "Creating child2 span and attaching to context") # Manually create a child span but don't save the token child = tracer.start_span("child2") ctx = trace.set_span_in_context(child) token = context.attach(ctx) # Token saved but not used later - + child_name = get_current_span_name() print_context_state(child_name, ["child2", "parent2"]) print_span_tree(["parent2", "child2"]) - + print_step(3, "Ending child2 span WITHOUT detaching token (improper cleanup)") # End the child span but don't detach the token child.end() # No context.detach(token) call! - + leaked_name = get_current_span_name() print_context_state(leaked_name, ["child2 (ended but context still active)", "parent2"]) print_span_tree(["parent2", "child2 (ended)"]) - + print("\n⚠️ Result: Context LEAK! Still showing child2 as current context even though span ended") print(" Any new spans created here would incorrectly use child2 as parent instead of parent2") @@ -256,44 +267,44 @@ def run_basic_scenarios(): outer_name = get_current_span_name() print_context_state(outer_name, ["outer"]) print_span_tree(["outer"]) - + print_step(2, "Creating middle1 span and attaching to context") # First middle span middle1 = tracer.start_span("middle1") ctx1 = trace.set_span_in_context(middle1) token1 = context.attach(ctx1) - + middle1_name = get_current_span_name() print_context_state(middle1_name, ["middle1", "outer"]) print_span_tree(["outer", "middle1"]) - + print_step(3, "Creating middle2 span and attaching to context") # Second middle span middle2 = tracer.start_span("middle2") ctx2 = trace.set_span_in_context(middle2) token2 = context.attach(ctx2) - + middle2_name = get_current_span_name() print_context_state(middle2_name, ["middle2", "middle1", "outer"]) print_span_tree(["outer", "middle1", "middle2"]) - + print_step(4, "Ending middle2 span and detaching token2") # End spans in reverse order with proper token management middle2.end() context.detach(token2) - + restored_middle1_name = get_current_span_name() print_context_state(restored_middle1_name, ["middle1", "outer"]) print_span_tree(["outer", "middle1", "middle2 (ended)"]) - + print_step(5, "Ending middle1 span and detaching token1") middle1.end() context.detach(token1) - + restored_outer_name = get_current_span_name() print_context_state(restored_outer_name, ["outer"]) print_span_tree(["outer", "middle1 (ended)", "middle2 (ended)"]) - + print("\n✅ Result: Context properly restored through multiple levels") # Scenario 4: What happens if we create new spans after a context leak @@ -306,62 +317,63 @@ def run_basic_scenarios(): root_name = get_current_span_name() print_context_state(root_name, ["root"]) print_span_tree(["root"]) - + print_step(2, "Creating leaky_child span and attaching to context") # Create a child span but don't save the token leaky = tracer.start_span("leaky_child") ctx = trace.set_span_in_context(leaky) context.attach(ctx) # Token not saved - + leaky_name = get_current_span_name() print_context_state(leaky_name, ["leaky_child", "root"]) print_span_tree(["root", "leaky_child"]) - + print_step(3, "Ending leaky_child span WITHOUT detaching token") # End the child span but don't detach the token leaky.end() # No context.detach() call! - + print_step(4, "Creating new_child span after context leak") # This span will be created with leaky_child as parent, not root! with tracer.start_as_current_span("new_child") as new_child: new_child_name = get_current_span_name() print_context_state(new_child_name, ["new_child", "leaky_child (ended but context active)", "root"]) print_span_tree(["root", "leaky_child (ended)", "new_child"]) - + print("\n⚠️ Problem: new_child is incorrectly parented to leaky_child instead of root") print(" This creates an incorrect trace hierarchy that doesn't match execution flow") + def run_advanced_scenarios(): """Run the new advanced scenarios demonstrating more complex context patterns""" - + # Scenario 5: Cross-function context propagation print_header("Scenario 5: Cross-Function Context Propagation") print("This scenario demonstrates how context and baggage propagate across function boundaries.") print("We'll create a request processing flow with multiple nested functions and spans.") - + print_step(1, "Starting user request processing with baggage") # Process a simulated request that will create nested spans across functions result = process_user_request("user-5678", "update_profile") - + print_step(2, "Request processing completed") print("\n Request processing result:") print(f" User data: {result['user']['name']}") print(f" Profile status: {result['profile']['status']}") - + print("\n✅ Result: Context and baggage successfully propagated across multiple function calls") print(" Each function created properly nested spans that maintained the baggage context") - + # Scenario 6: Using different tracers with the same context print_header("Scenario 6: Multiple Tracers with Shared Context") print("This scenario demonstrates using multiple tracers while maintaining a consistent context.") - + print_step(1, "Creating context with baggage") # Set up a context with baggage ctx = baggage.set_baggage("environment", "production") ctx = baggage.set_baggage("tenant.id", "tenant-9876", context=ctx) token = context.attach(ctx) - + try: print_step(2, "Starting span with main tracer") with tracer.start_as_current_span("main_operation") as main_span: @@ -369,31 +381,33 @@ def run_advanced_scenarios(): baggage_items = get_current_baggage() print_context_state(main_span_name, ["main_operation"], baggage_items) print_span_details(main_span) - + print_step(3, "Creating span with LLM tracer (different tracer)") with llm_tracer.start_as_current_span("llm_inference") as llm_span: llm_span.set_attribute("model", "gpt-4") llm_span.set_attribute("tokens", 150) - + llm_span_name = get_current_span_name() print_context_state(llm_span_name, ["llm_inference", "main_operation"], baggage_items) print_span_details(llm_span, "LLM Span Details") - + print_step(4, "Back to main tracer") # Create another span with the first tracer with tracer.start_as_current_span("post_processing") as post_span: post_span_name = get_current_span_name() - print_context_state(post_span_name, ["post_processing", "llm_inference", "main_operation"], baggage_items) + print_context_state( + post_span_name, ["post_processing", "llm_inference", "main_operation"], baggage_items + ) finally: context.detach(token) - + print("\n✅ Result: Multiple tracers successfully shared the same context") print(" Baggage was accessible to spans from both tracers") - + # Scenario 7: Handling errors in spans print_header("Scenario 7: Error Handling in Spans") print("This scenario demonstrates proper error handling with spans.") - + print_step(1, "Starting operation that will encounter an error") with tracer.start_as_current_span("error_prone_operation") as error_span: try: @@ -405,30 +419,33 @@ def run_advanced_scenarios(): error_span.record_exception(e) error_span.set_status(Status(StatusCode.ERROR)) print(f" Recorded exception: {str(e)}") - + print("\n✅ Result: Properly recorded error in span without breaking execution flow") print(" Errors should be visible in the trace visualization") - + # Scenario 8: Manual context saving and restoring print_header("Scenario 8: Manual Context Saving and Restoring") print("This scenario demonstrates saving a context and restoring it later.") - + print_step(1, "Creating initial context") with tracer.start_as_current_span("initial_operation") as initial_span: # Set some baggage ctx = baggage.set_baggage("checkpoint", "saved_point") - + # Save the current context for later use saved_context = context.get_current() print_context_state("initial_operation", ["initial_operation"], {"checkpoint": "saved_point"}) - + print_step(2, "Creating a different context") with tracer.start_as_current_span("intermediate_operation") as intermediate_span: # Change the baggage ctx = baggage.set_baggage("checkpoint", "intermediate_point") - print_context_state("intermediate_operation", ["intermediate_operation", "initial_operation"], - {"checkpoint": "intermediate_point"}) - + print_context_state( + "intermediate_operation", + ["intermediate_operation", "initial_operation"], + {"checkpoint": "intermediate_point"}, + ) + print_step(3, "Restoring saved context") # Restore the saved context token = context.attach(saved_context) @@ -437,15 +454,19 @@ def run_advanced_scenarios(): current_name = getattr(current_span, "name", "Unknown") checkpoint = baggage.get_baggage("checkpoint") print_context_state(current_name, ["initial_operation"], {"checkpoint": checkpoint}) - + print("\n✅ Result: Successfully restored previous context") finally: context.detach(token) - + print_step(4, "Back to intermediate context") - print_context_state("intermediate_operation", ["intermediate_operation", "initial_operation"], - {"checkpoint": "intermediate_point"}) - + print_context_state( + "intermediate_operation", + ["intermediate_operation", "initial_operation"], + {"checkpoint": "intermediate_point"}, + ) + + print_header("OpenTelemetry Context Management Demonstration") print("This example illustrates the importance of proper context management in OpenTelemetry.") print("It covers basic and advanced scenarios showing how context affects span relationships.") @@ -457,7 +478,7 @@ def run_advanced_scenarios(): while True: choice = input("\nEnter your choice (1-4): ") - + if choice == "1": run_basic_scenarios() elif choice == "2": diff --git a/examples/opentelemetry/token_importance_2.py b/examples/opentelemetry/token_importance_2.py index aea2fe1f4..024c4a30c 100644 --- a/examples/opentelemetry/token_importance_2.py +++ b/examples/opentelemetry/token_importance_2.py @@ -9,9 +9,11 @@ trace.set_tracer_provider(provider) tracer = trace.get_tracer("demo") + def get_current_span_name(): return getattr(trace.get_current_span(), "name", "None") + print("\n=== Scenario: Multiple contexts with the same span ===") print("This demonstrates why coupling spans and tokens can be problematic") diff --git a/examples/sdk/basic.py b/examples/sdk/basic.py index c8dcb879b..6c008d4f9 100644 --- a/examples/sdk/basic.py +++ b/examples/sdk/basic.py @@ -6,7 +6,6 @@ @agent class Agent: - @operation def nested_operation(self): print("Hello, world!") diff --git a/test_context.py b/test_context.py index afaf49b18..340cb338f 100644 --- a/test_context.py +++ b/test_context.py @@ -21,31 +21,34 @@ if hasattr(provider, "add_span_processor"): provider.add_span_processor(BatchSpanProcessor(ConsoleSpanExporter())) + @agent def my_agent(): """Test agent function that should create a parent span""" logger.debug(f"In my_agent - current span: {_get_current_span_info()}") - + # Call the task inside the agent result = my_task() - + # Also explicitly call operation with a context manager tracer = TracingCore.get_instance().get_tracer() with tracer.start_as_current_span("manual_operation") as manual_span: manual_span.set_attribute("manual", True) logger.debug(f"In manual operation - current span: {_get_current_span_info()}") time.sleep(0.1) - + return result + @task def my_task(): """Test task function that should create a child span under the agent span""" logger.debug(f"In my_task - current span: {_get_current_span_info()}") - + # Call a nested operation return my_operation() + @operation def my_operation(): """Test operation that should be nested under the task span""" @@ -53,10 +56,11 @@ def my_operation(): time.sleep(0.1) return "done" + if __name__ == "__main__": # Run the test result = my_agent() print(f"Result: {result}") - + # Give the batch processor time to export - time.sleep(1) \ No newline at end of file + time.sleep(1) diff --git a/test_context_comparison.py b/test_context_comparison.py index db2de3825..79c9e64f8 100644 --- a/test_context_comparison.py +++ b/test_context_comparison.py @@ -9,8 +9,7 @@ from agentops.sdk.decorators import agent, task, operation from agentops.sdk.core import TracingCore from agentops.client.client import Client -from agentops.sdk.decorators.utility import (_get_current_span_info, _make_span, - _finalize_span, _create_as_current_span) +from agentops.sdk.decorators.utility import _get_current_span_info, _make_span, _finalize_span, _create_as_current_span from agentops.logging import logger # Initialize tracing @@ -22,71 +21,74 @@ if hasattr(provider, "add_span_processor"): provider.add_span_processor(BatchSpanProcessor(ConsoleSpanExporter())) + def test_manual_context(): """Test using the manual context management approach""" logger.debug("===== TESTING MANUAL CONTEXT APPROACH =====") - + # Create the root span root_span, root_ctx, root_token = _make_span("root", "test") logger.debug(f"Created root span: {_get_current_span_info()}") - + try: # Create a child span child_span, child_ctx, child_token = _make_span("child", "test") logger.debug(f"Created child span: {_get_current_span_info()}") - + try: # Create a grandchild span grandchild_span, grandchild_ctx, grandchild_token = _make_span("grandchild", "test") logger.debug(f"Created grandchild span: {_get_current_span_info()}") - + # Do some work time.sleep(0.1) - + # End the grandchild span _finalize_span(grandchild_span, grandchild_token) logger.debug(f"After ending grandchild span: {_get_current_span_info()}") - + finally: # End the child span _finalize_span(child_span, child_token) logger.debug(f"After ending child span: {_get_current_span_info()}") - + finally: # End the root span _finalize_span(root_span, root_token) logger.debug(f"After ending root span: {_get_current_span_info()}") + def test_context_manager(): """Test using the context manager approach""" logger.debug("===== TESTING CONTEXT MANAGER APPROACH =====") - + # Get a tracer tracer = TracingCore.get_instance().get_tracer() - + # Create spans using context manager (native OpenTelemetry approach) with _create_as_current_span("root", "test") as root_span: logger.debug(f"Created root span: {_get_current_span_info()}") - + with _create_as_current_span("child", "test") as child_span: logger.debug(f"Created child span: {_get_current_span_info()}") - + with _create_as_current_span("grandchild", "test") as grandchild_span: logger.debug(f"Created grandchild span: {_get_current_span_info()}") - + # Do some work time.sleep(0.1) - + logger.debug(f"After grandchild span: {_get_current_span_info()}") - + logger.debug(f"After child span: {_get_current_span_info()}") - + logger.debug(f"After root span: {_get_current_span_info()}") + if __name__ == "__main__": # Test both approaches test_manual_context() test_context_manager() - + # Give the batch processor time to export - time.sleep(1) \ No newline at end of file + time.sleep(1) diff --git a/test_nesting.py b/test_nesting.py index 5cf686b4e..2fa90b07b 100644 --- a/test_nesting.py +++ b/test_nesting.py @@ -5,6 +5,7 @@ # Initialize tracing TracingCore.get_instance().initialize() + @operation def perform_operation(task_name): """A simple operation that will be nested within an agent.""" @@ -12,21 +13,23 @@ def perform_operation(task_name): time.sleep(0.5) # Simulate work return f"Completed {task_name}" + @agent def run_agent(agent_name): """An agent that will contain nested operations.""" print(f"Agent {agent_name} is running") - + # Perform multiple operations result1 = perform_operation("task1") result2 = perform_operation("task2") - + return f"Agent {agent_name} completed with results: {result1}, {result2}" + if __name__ == "__main__": # Run the agent which will contain nested operations result = run_agent("TestAgent") print(f"Final result: {result}") - + # Give time for spans to be exported - time.sleep(1) \ No newline at end of file + time.sleep(1) diff --git a/tests/unit/sdk/instrumentation_tester.py b/tests/unit/sdk/instrumentation_tester.py index 48e7fd63c..d26bf10ee 100644 --- a/tests/unit/sdk/instrumentation_tester.py +++ b/tests/unit/sdk/instrumentation_tester.py @@ -4,8 +4,7 @@ from opentelemetry import trace as trace_api from opentelemetry.sdk.trace import ReadableSpan, Span, TracerProvider from opentelemetry.sdk.trace.export import SimpleSpanProcessor -from opentelemetry.sdk.trace.export.in_memory_span_exporter import \ - InMemorySpanExporter +from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter from opentelemetry.util.types import Attributes import agentops @@ -40,7 +39,7 @@ def reset_trace_globals(): """Reset the global trace state to avoid conflicts.""" # Reset tracer provider trace_api._TRACER_PROVIDER = None - + # Reload the trace module to clear warning state importlib.reload(trace_api) @@ -74,10 +73,10 @@ def __init__(self): """Initialize the instrumentation tester.""" # Reset any global state first reset_trace_globals() - + # Shut down any existing tracing core self._shutdown_core() - + # Create a new tracer provider and memory exporter ( self.tracer_provider, @@ -120,10 +119,10 @@ def reset(self): # Clear any existing spans self.clear_spans() - + # Reset global trace state reset_trace_globals() - + # Set our tracer provider again trace_api.set_tracer_provider(self.tracer_provider) diff --git a/tests/unit/sdk/test_decorators.py b/tests/unit/sdk/test_decorators.py index fa389e59e..bd444386a 100644 --- a/tests/unit/sdk/test_decorators.py +++ b/tests/unit/sdk/test_decorators.py @@ -61,10 +61,15 @@ def test_session(): assert len(spans) == 4 # Verify span kinds - session_spans = [s for s in spans if s.attributes and s.attributes.get(SpanAttributes.AGENTOPS_SPAN_KIND) == SpanKind.SESSION] - agent_spans = [s for s in spans if s.attributes and s.attributes.get(SpanAttributes.AGENTOPS_SPAN_KIND) == SpanKind.AGENT] - operation_spans = [s for s in spans if s.attributes and s.attributes.get( - SpanAttributes.AGENTOPS_SPAN_KIND) == SpanKind.TASK] + session_spans = [ + s for s in spans if s.attributes and s.attributes.get(SpanAttributes.AGENTOPS_SPAN_KIND) == SpanKind.SESSION + ] + agent_spans = [ + s for s in spans if s.attributes and s.attributes.get(SpanAttributes.AGENTOPS_SPAN_KIND) == SpanKind.AGENT + ] + operation_spans = [ + s for s in spans if s.attributes and s.attributes.get(SpanAttributes.AGENTOPS_SPAN_KIND) == SpanKind.TASK + ] assert len(session_spans) == 1 assert len(agent_spans) == 1 @@ -73,31 +78,31 @@ def test_session(): # Find the main_operation and nested_operation spans main_operation = None nested_operation = None - + for span in operation_spans: - if span.attributes and span.attributes.get('agentops.operation.name') == 'main_operation': + if span.attributes and span.attributes.get("agentops.operation.name") == "main_operation": main_operation = span - elif span.attributes and span.attributes.get('agentops.operation.name') == 'nested_operation': + elif span.attributes and span.attributes.get("agentops.operation.name") == "nested_operation": nested_operation = span - + assert main_operation is not None, "main_operation span not found" assert nested_operation is not None, "nested_operation span not found" - + # Verify the session span is the root session_span = session_spans[0] assert session_span.parent is None - + # Verify the agent span is a child of the session span agent_span = agent_spans[0] assert agent_span.parent is not None assert session_span.context is not None assert agent_span.parent.span_id == session_span.context.span_id - + # Verify main_operation is a child of the agent span assert main_operation.parent is not None assert agent_span.context is not None assert main_operation.parent.span_id == agent_span.context.span_id - + # Verify nested_operation is a child of main_operation assert nested_operation.parent is not None assert main_operation.context is not None @@ -150,10 +155,15 @@ async def test_async_session(): assert len(spans) == 4 # Verify span kinds - session_spans = [s for s in spans if s.attributes and s.attributes.get(SpanAttributes.AGENTOPS_SPAN_KIND) == SpanKind.SESSION] - agent_spans = [s for s in spans if s.attributes and s.attributes.get(SpanAttributes.AGENTOPS_SPAN_KIND) == SpanKind.AGENT] - operation_spans = [s for s in spans if s.attributes and s.attributes.get( - SpanAttributes.AGENTOPS_SPAN_KIND) == SpanKind.TASK] + session_spans = [ + s for s in spans if s.attributes and s.attributes.get(SpanAttributes.AGENTOPS_SPAN_KIND) == SpanKind.SESSION + ] + agent_spans = [ + s for s in spans if s.attributes and s.attributes.get(SpanAttributes.AGENTOPS_SPAN_KIND) == SpanKind.AGENT + ] + operation_spans = [ + s for s in spans if s.attributes and s.attributes.get(SpanAttributes.AGENTOPS_SPAN_KIND) == SpanKind.TASK + ] assert len(session_spans) == 1 assert len(agent_spans) == 1 @@ -162,31 +172,31 @@ async def test_async_session(): # Find the main_operation and nested_operation spans main_operation = None nested_operation = None - + for span in operation_spans: - if span.attributes and span.attributes.get('agentops.operation.name') == 'main_async_operation': + if span.attributes and span.attributes.get("agentops.operation.name") == "main_async_operation": main_operation = span - elif span.attributes and span.attributes.get('agentops.operation.name') == 'nested_async_operation': + elif span.attributes and span.attributes.get("agentops.operation.name") == "nested_async_operation": nested_operation = span - + assert main_operation is not None, "main_async_operation span not found" assert nested_operation is not None, "nested_async_operation span not found" - + # Verify the session span is the root session_span = session_spans[0] assert session_span.parent is None - + # Verify the agent span is a child of the session span agent_span = agent_spans[0] assert agent_span.parent is not None assert session_span.context is not None assert agent_span.parent.span_id == session_span.context.span_id - + # Verify main_operation is a child of the agent span assert main_operation.parent is not None assert agent_span.context is not None assert main_operation.parent.span_id == agent_span.context.span_id - + # Verify nested_operation is a child of main_operation assert nested_operation.parent is not None assert main_operation.context is not None @@ -241,10 +251,15 @@ def test_generator_session(): assert len(spans) == 4 # Verify span kinds - session_spans = [s for s in spans if s.attributes and s.attributes.get(SpanAttributes.AGENTOPS_SPAN_KIND) == SpanKind.SESSION] - agent_spans = [s for s in spans if s.attributes and s.attributes.get(SpanAttributes.AGENTOPS_SPAN_KIND) == SpanKind.AGENT] - operation_spans = [s for s in spans if s.attributes and s.attributes.get( - SpanAttributes.AGENTOPS_SPAN_KIND) == SpanKind.TASK] + session_spans = [ + s for s in spans if s.attributes and s.attributes.get(SpanAttributes.AGENTOPS_SPAN_KIND) == SpanKind.SESSION + ] + agent_spans = [ + s for s in spans if s.attributes and s.attributes.get(SpanAttributes.AGENTOPS_SPAN_KIND) == SpanKind.AGENT + ] + operation_spans = [ + s for s in spans if s.attributes and s.attributes.get(SpanAttributes.AGENTOPS_SPAN_KIND) == SpanKind.TASK + ] assert len(session_spans) == 1 assert len(agent_spans) == 1 @@ -253,31 +268,31 @@ def test_generator_session(): # Find the main_operation and nested_operation spans main_operation = None nested_operation = None - + for span in operation_spans: - if span.attributes and span.attributes.get('agentops.operation.name') == 'main_generator_operation': + if span.attributes and span.attributes.get("agentops.operation.name") == "main_generator_operation": main_operation = span - elif span.attributes and span.attributes.get('agentops.operation.name') == 'nested_generator': + elif span.attributes and span.attributes.get("agentops.operation.name") == "nested_generator": nested_operation = span - + assert main_operation is not None, "main_generator_operation span not found" assert nested_operation is not None, "nested_generator span not found" - + # Verify the session span is the root session_span = session_spans[0] assert session_span.parent is None - + # Verify the agent span is a child of the session span agent_span = agent_spans[0] assert agent_span.parent is not None assert session_span.context is not None assert agent_span.parent.span_id == session_span.context.span_id - + # Verify main_operation is a child of the agent span assert main_operation.parent is not None assert agent_span.context is not None assert main_operation.parent.span_id == agent_span.context.span_id - + # Verify nested_operation is a child of main_operation assert nested_operation.parent is not None assert main_operation.context is not None @@ -333,10 +348,15 @@ async def test_async_generator_session(): assert len(spans) == 4 # Verify span kinds - session_spans = [s for s in spans if s.attributes and s.attributes.get(SpanAttributes.AGENTOPS_SPAN_KIND) == SpanKind.SESSION] - agent_spans = [s for s in spans if s.attributes and s.attributes.get(SpanAttributes.AGENTOPS_SPAN_KIND) == SpanKind.AGENT] - operation_spans = [s for s in spans if s.attributes and s.attributes.get( - SpanAttributes.AGENTOPS_SPAN_KIND) == SpanKind.TASK] + session_spans = [ + s for s in spans if s.attributes and s.attributes.get(SpanAttributes.AGENTOPS_SPAN_KIND) == SpanKind.SESSION + ] + agent_spans = [ + s for s in spans if s.attributes and s.attributes.get(SpanAttributes.AGENTOPS_SPAN_KIND) == SpanKind.AGENT + ] + operation_spans = [ + s for s in spans if s.attributes and s.attributes.get(SpanAttributes.AGENTOPS_SPAN_KIND) == SpanKind.TASK + ] assert len(session_spans) == 1 assert len(agent_spans) == 1 @@ -345,31 +365,31 @@ async def test_async_generator_session(): # Find the main_operation and nested_operation spans main_operation = None nested_operation = None - + for span in operation_spans: - if span.attributes and span.attributes.get('agentops.operation.name') == 'main_async_generator_operation': + if span.attributes and span.attributes.get("agentops.operation.name") == "main_async_generator_operation": main_operation = span - elif span.attributes and span.attributes.get('agentops.operation.name') == 'nested_async_generator': + elif span.attributes and span.attributes.get("agentops.operation.name") == "nested_async_generator": nested_operation = span - + assert main_operation is not None, "main_async_generator_operation span not found" assert nested_operation is not None, "nested_async_generator span not found" - + # Verify the session span is the root session_span = session_spans[0] assert session_span.parent is None - + # Verify the agent span is a child of the session span agent_span = agent_spans[0] assert agent_span.parent is not None assert session_span.context is not None assert agent_span.parent.span_id == session_span.context.span_id - + # Verify main_operation is a child of the agent span assert main_operation.parent is not None assert agent_span.context is not None assert main_operation.parent.span_id == agent_span.context.span_id - + # Verify nested_operation is a child of main_operation assert nested_operation.parent is not None assert main_operation.context is not None @@ -427,10 +447,15 @@ def test_complex_session(): assert len(spans) == 5 # Verify span kinds - session_spans = [s for s in spans if s.attributes and s.attributes.get(SpanAttributes.AGENTOPS_SPAN_KIND) == SpanKind.SESSION] - agent_spans = [s for s in spans if s.attributes and s.attributes.get(SpanAttributes.AGENTOPS_SPAN_KIND) == SpanKind.AGENT] - operation_spans = [s for s in spans if s.attributes and s.attributes.get( - SpanAttributes.AGENTOPS_SPAN_KIND) == SpanKind.TASK] + session_spans = [ + s for s in spans if s.attributes and s.attributes.get(SpanAttributes.AGENTOPS_SPAN_KIND) == SpanKind.SESSION + ] + agent_spans = [ + s for s in spans if s.attributes and s.attributes.get(SpanAttributes.AGENTOPS_SPAN_KIND) == SpanKind.AGENT + ] + operation_spans = [ + s for s in spans if s.attributes and s.attributes.get(SpanAttributes.AGENTOPS_SPAN_KIND) == SpanKind.TASK + ] assert len(session_spans) == 1 assert len(agent_spans) == 1 @@ -440,42 +465,40 @@ def test_complex_session(): level1_operation = None level2_operation = None level3_operation = None - + for span in operation_spans: - if span.attributes and span.attributes.get('agentops.operation.name') == 'level1_operation': + if span.attributes and span.attributes.get("agentops.operation.name") == "level1_operation": level1_operation = span - elif span.attributes and span.attributes.get('agentops.operation.name') == 'level2_operation': + elif span.attributes and span.attributes.get("agentops.operation.name") == "level2_operation": level2_operation = span - elif span.attributes and span.attributes.get('agentops.operation.name') == 'level3_operation': + elif span.attributes and span.attributes.get("agentops.operation.name") == "level3_operation": level3_operation = span - + assert level1_operation is not None, "level1_operation span not found" assert level2_operation is not None, "level2_operation span not found" assert level3_operation is not None, "level3_operation span not found" - + # Verify the session span is the root session_span = session_spans[0] assert session_span.parent is None - + # Verify the agent span is a child of the session span agent_span = agent_spans[0] assert agent_span.parent is not None assert session_span.context is not None assert agent_span.parent.span_id == session_span.context.span_id - + # Verify level1_operation is a child of the agent span assert level1_operation.parent is not None assert agent_span.context is not None assert level1_operation.parent.span_id == agent_span.context.span_id - + # Verify level2_operation is a child of level1_operation assert level2_operation.parent is not None assert level1_operation.context is not None assert level2_operation.parent.span_id == level1_operation.context.span_id - + # Verify level3_operation is a child of level2_operation assert level3_operation.parent is not None assert level2_operation.context is not None assert level3_operation.parent.span_id == level2_operation.context.span_id - - diff --git a/third_party/opentelemetry/instrumentation/agents/agentops_agents_instrumentor.py b/third_party/opentelemetry/instrumentation/agents/agentops_agents_instrumentor.py index 2f1e75ef5..fb69f028f 100644 --- a/third_party/opentelemetry/instrumentation/agents/agentops_agents_instrumentor.py +++ b/third_party/opentelemetry/instrumentation/agents/agentops_agents_instrumentor.py @@ -42,6 +42,25 @@ logger = logging.getLogger(__name__) + +def model_as_dict(model): + """Convert a model object to a dictionary safely.""" + if isinstance(model, dict): + return model + if hasattr(model, "model_dump"): + return model.model_dump() + elif hasattr(model, "dict"): + return model.dict() + elif hasattr(model, "parse"): # Raw API response + return model_as_dict(model.parse()) + else: + # Try to use __dict__ as fallback + try: + return model.__dict__ + except: + return model + + # Global metrics objects _agent_run_counter = None _agent_turn_counter = None @@ -182,10 +201,77 @@ def _export_span(self, span: AgentsSpan[Any]) -> None: attributes[AgentAttributes.AGENT_NAME] = span_data.name if hasattr(span_data, "input") and span_data.input: - attributes[SpanAttributes.LLM_PROMPTS] = str(span_data.input)[:1000] # Truncate long inputs + attributes[SpanAttributes.LLM_PROMPTS] = str(span_data.input)[:1000] # Keep truncation for input for now + # Handle output - extract specific fields instead of using str() if hasattr(span_data, "output") and span_data.output: - attributes[SpanAttributes.LLM_COMPLETIONS] = str(span_data.output)[:1000] # Truncate long outputs + output = span_data.output + + # Convert to dict if possible using model_as_dict + try: + output_dict = model_as_dict(output) + except Exception: + # If conversion fails, try to access attributes directly + output_dict = None + + if output_dict: + # Extract model + if "model" in output_dict: + attributes[SpanAttributes.LLM_RESPONSE_MODEL] = output_dict["model"] + + # Extract ID + if "id" in output_dict: + attributes[SpanAttributes.LLM_RESPONSE_ID] = output_dict["id"] + + # Extract system fingerprint (OpenAI specific) + if "system_fingerprint" in output_dict: + attributes[SpanAttributes.LLM_OPENAI_RESPONSE_SYSTEM_FINGERPRINT] = output_dict[ + "system_fingerprint" + ] + + # Handle usage metrics + if "usage" in output_dict and output_dict["usage"]: + usage = output_dict["usage"] + if isinstance(usage, dict): + if "total_tokens" in usage: + attributes[SpanAttributes.LLM_USAGE_TOTAL_TOKENS] = usage["total_tokens"] + if "completion_tokens" in usage: + attributes[SpanAttributes.LLM_USAGE_COMPLETION_TOKENS] = usage["completion_tokens"] + if "prompt_tokens" in usage: + attributes[SpanAttributes.LLM_USAGE_PROMPT_TOKENS] = usage["prompt_tokens"] + + # Handle completions - extract specific fields from choices + if "choices" in output_dict and output_dict["choices"]: + for choice in output_dict["choices"]: + index = choice.get("index", 0) + prefix = f"{SpanAttributes.LLM_COMPLETIONS}.{index}" + + # Extract finish reason + if "finish_reason" in choice: + attributes[f"{prefix}.finish_reason"] = choice["finish_reason"] + + # Extract message content + message = choice.get("message", {}) + if message: + if "role" in message: + attributes[f"{prefix}.role"] = message["role"] + if "content" in message: + attributes[f"{prefix}.content"] = message["content"] + + # Handle function calls if present + if "function_call" in message: + function_call = message["function_call"] + attributes[f"{prefix}.function_call.name"] = function_call.get("name") + attributes[f"{prefix}.function_call.arguments"] = function_call.get("arguments") + + # Handle tool calls if present + if "tool_calls" in message: + for i, tool_call in enumerate(message["tool_calls"]): + if "function" in tool_call: + function = tool_call["function"] + attributes[f"{prefix}.tool_calls.{i}.id"] = tool_call.get("id") + attributes[f"{prefix}.tool_calls.{i}.name"] = function.get("name") + attributes[f"{prefix}.tool_calls.{i}.arguments"] = function.get("arguments") # Extract model information - check for GenerationSpanData specifically if span_type == "Generation" and hasattr(span_data, "model") and span_data.model: