Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions sdk/ai/azure-ai-projects/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@
* Rename class `ItemParam` to `InputItem`.
* Tracing: workflow actions in conversation item listings are now emitted as "gen_ai.conversation.item" events (with role="workflow") instead of "gen_ai.workflow.action" events in the list_conversation_items span.
* Tracing: response generation span names changed from "responses {model_name}" to "chat {model_name}" for model calls and from "responses {agent_name}" to "invoke_agent {agent_name}" for agent calls.
* Tracing: response generation operation names changed from "responses" to "chat" for model calls and from "responses" to "invoke_agent" for agent calls.
* Tracing: response generation uses gen_ai.input.messages and gen_ai.output.messages attributes directly under the span instead of events.

## 2.0.0b3 (2026-01-06)

Expand Down
2 changes: 1 addition & 1 deletion sdk/ai/azure-ai-projects/assets.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,5 @@
"AssetsRepo": "Azure/azure-sdk-assets",
"AssetsRepoPrefixPath": "python",
"TagPrefix": "python/ai/azure-ai-projects",
"Tag": "python/ai/azure-ai-projects_7cddb7d06f"
"Tag": "python/ai/azure-ai-projects_4fb2407dfd"
}
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,11 @@
GEN_AI_CONVERSATION_ITEM_EVENT,
GEN_AI_CONVERSATION_ITEM_ID,
GEN_AI_EVENT_CONTENT,
GEN_AI_INPUT_MESSAGES,
GEN_AI_OPENAI_RESPONSE_SERVICE_TIER,
GEN_AI_OPENAI_RESPONSE_SYSTEM_FINGERPRINT,
GEN_AI_OPERATION_NAME,
GEN_AI_OUTPUT_MESSAGES,
GEN_AI_PROVIDER_NAME,
GEN_AI_REQUEST_MODEL,
GEN_AI_REQUEST_TOOLS,
Expand All @@ -48,14 +50,18 @@
GEN_AI_USAGE_OUTPUT_TOKENS,
GEN_AI_USER_MESSAGE_EVENT,
GEN_AI_WORKFLOW_ACTION_EVENT,
OPERATION_NAME_CHAT,
OPERATION_NAME_INVOKE_AGENT,
OperationName,
SERVER_ADDRESS,
SERVER_PORT,
SPAN_NAME_CHAT,
SPAN_NAME_INVOKE_AGENT,
_get_use_message_events,
start_span,
)


_Unset: Any = object()

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -534,6 +540,35 @@ def _create_event_attributes(
# attrs[GEN_AI_MESSAGE_ROLE] = message_role
return attrs

def _append_to_message_attribute(
self,
span: "AbstractSpan",
attribute_name: str,
new_messages: List[Dict[str, Any]],
) -> None:
"""Helper to append messages to an existing attribute, combining with previous messages."""
# Get existing attribute value
existing_value = span.span_instance.attributes.get(attribute_name) if span.span_instance.attributes else None

if existing_value:
# Parse existing JSON array
try:
existing_messages = json.loads(existing_value)
if not isinstance(existing_messages, list):
existing_messages = []
except (json.JSONDecodeError, TypeError):
existing_messages = []

# Append new messages
combined_messages = existing_messages + new_messages
else:
# No existing value, just use new messages
combined_messages = new_messages

# Set the combined value
combined_json = json.dumps(combined_messages, ensure_ascii=False)
span.add_attribute(attribute_name, combined_json)

def _add_message_event(
self,
span: "AbstractSpan",
Expand All @@ -542,7 +577,7 @@ def _add_message_event(
conversation_id: Optional[str] = None,
finish_reason: Optional[str] = None,
) -> None:
"""Add a message event to the span."""
"""Add a message event or attribute to the span based on configuration."""
content_array: List[Dict[str, Any]] = []

# Always include role and finish_reason, only include actual content if tracing is enabled
Expand All @@ -567,23 +602,37 @@ def _add_message_event(

content_array.append(role_obj)

attributes = self._create_event_attributes(
conversation_id=conversation_id,
message_role=role,
)
# Store as JSON array directly without outer wrapper
attributes[GEN_AI_EVENT_CONTENT] = json.dumps(content_array, ensure_ascii=False)

# Map role to appropriate event name constant
if role == "user":
event_name = GEN_AI_USER_MESSAGE_EVENT
elif role == "assistant":
event_name = GEN_AI_ASSISTANT_MESSAGE_EVENT
else:
# Fallback for any other roles (shouldn't happen in practice)
event_name = f"gen_ai.{role}.message"
# Serialize the content array to JSON
json_content = json.dumps(content_array, ensure_ascii=False)

span.span_instance.add_event(name=event_name, attributes=attributes)
if _get_use_message_events():
# Original event-based implementation
attributes = self._create_event_attributes(
conversation_id=conversation_id,
message_role=role,
)
# Store as JSON array directly without outer wrapper
attributes[GEN_AI_EVENT_CONTENT] = json_content

# Map role to appropriate event name constant
if role == "user":
event_name = GEN_AI_USER_MESSAGE_EVENT
elif role == "assistant":
event_name = GEN_AI_ASSISTANT_MESSAGE_EVENT
else:
# Fallback for any other roles (shouldn't happen in practice)
event_name = f"gen_ai.{role}.message"

span.span_instance.add_event(name=event_name, attributes=attributes)
else:
# New attribute-based implementation
# Append messages to the appropriate attribute (accumulating multiple messages)
if role in ("user", "tool"):
# User and tool messages go to input.messages
self._append_to_message_attribute(span, GEN_AI_INPUT_MESSAGES, content_array)
elif role == "assistant":
# Assistant messages go to output.messages
self._append_to_message_attribute(span, GEN_AI_OUTPUT_MESSAGES, content_array)

def _add_tool_message_events( # pylint: disable=too-many-branches
self,
Expand Down Expand Up @@ -671,15 +720,20 @@ def _add_tool_message_events( # pylint: disable=too-many-branches
# Always include parts array with type and id, even when content recording is disabled
content_array = [{"role": "tool", "parts": parts}] if parts else []

attributes = self._create_event_attributes(
conversation_id=conversation_id,
message_role="tool",
)
# Store as JSON array directly without outer wrapper
attributes[GEN_AI_EVENT_CONTENT] = json.dumps(content_array, ensure_ascii=False)
if _get_use_message_events():
# Event-based mode: add events
attributes = self._create_event_attributes(
conversation_id=conversation_id,
message_role="tool",
)
# Store as JSON array directly without outer wrapper
attributes[GEN_AI_EVENT_CONTENT] = json.dumps(content_array, ensure_ascii=False)

# Use "tool" for the event name: gen_ai.tool.message
span.span_instance.add_event(name=GEN_AI_TOOL_MESSAGE_EVENT, attributes=attributes)
# Use "tool" for the event name: gen_ai.tool.message
span.span_instance.add_event(name=GEN_AI_TOOL_MESSAGE_EVENT, attributes=attributes)
else:
# Attribute-based mode: append to input messages (tool outputs are inputs to the model)
self._append_to_message_attribute(span, GEN_AI_INPUT_MESSAGES, content_array)

def _add_mcp_response_events(
self,
Expand Down Expand Up @@ -748,15 +802,20 @@ def _add_mcp_response_events(
# Always include parts array with type and id, even when content recording is disabled
content_array = [{"role": "user", "parts": parts}] if parts else []

attributes = self._create_event_attributes(
conversation_id=conversation_id,
message_role="user",
)
# Store as JSON array directly without outer wrapper
attributes[GEN_AI_EVENT_CONTENT] = json.dumps(content_array, ensure_ascii=False)
if _get_use_message_events():
# Event-based mode: add events
attributes = self._create_event_attributes(
conversation_id=conversation_id,
message_role="user",
)
# Store as JSON array directly without outer wrapper
attributes[GEN_AI_EVENT_CONTENT] = json.dumps(content_array, ensure_ascii=False)

# Use user message event name since MCP responses are user inputs
span.span_instance.add_event(name=GEN_AI_USER_MESSAGE_EVENT, attributes=attributes)
# Use user message event name since MCP responses are user inputs
span.span_instance.add_event(name=GEN_AI_USER_MESSAGE_EVENT, attributes=attributes)
else:
# Attribute-based mode: append to input messages (MCP responses are user inputs)
self._append_to_message_attribute(span, GEN_AI_INPUT_MESSAGES, content_array)

def _add_workflow_action_events(
self,
Expand Down Expand Up @@ -940,25 +999,36 @@ def _add_structured_input_events(
role_obj["parts"] = parts
content_array = [role_obj]

# Create event attributes
attributes = self._create_event_attributes(
conversation_id=conversation_id,
message_role=role,
)
# Store as JSON array directly without outer wrapper
attributes[GEN_AI_EVENT_CONTENT] = json.dumps(content_array, ensure_ascii=False)
if _get_use_message_events():
# Event-based mode
# Create event attributes
attributes = self._create_event_attributes(
conversation_id=conversation_id,
message_role=role,
)
# Store as JSON array directly without outer wrapper
attributes[GEN_AI_EVENT_CONTENT] = json.dumps(content_array, ensure_ascii=False)

# Map role to appropriate event name constant
if role == "user":
event_name = GEN_AI_USER_MESSAGE_EVENT
elif role == "assistant":
event_name = GEN_AI_ASSISTANT_MESSAGE_EVENT
else:
# Fallback for any other roles (shouldn't happen in practice)
event_name = f"gen_ai.{role}.message"

# Map role to appropriate event name constant
if role == "user":
event_name = GEN_AI_USER_MESSAGE_EVENT
elif role == "assistant":
event_name = GEN_AI_ASSISTANT_MESSAGE_EVENT
# Add the event
span.span_instance.add_event(name=event_name, attributes=attributes)
else:
# Fallback for any other roles (shouldn't happen in practice)
event_name = f"gen_ai.{role}.message"

# Add the event
span.span_instance.add_event(name=event_name, attributes=attributes)
# Attribute-based mode
# Append messages to the appropriate attribute
if role in ("user", "tool"):
# User and tool messages go to input.messages
self._append_to_message_attribute(span, GEN_AI_INPUT_MESSAGES, content_array)
elif role == "assistant":
# Assistant messages go to output.messages
self._append_to_message_attribute(span, GEN_AI_OUTPUT_MESSAGES, content_array)

except Exception: # pylint: disable=broad-exception-caught
# Skip items that can't be processed
Expand All @@ -975,37 +1045,51 @@ def _emit_tool_call_event(
tool_call: Dict[str, Any],
conversation_id: Optional[str] = None,
) -> None:
"""Helper to emit a single tool call event."""
"""Helper to emit a single tool call event or attribute."""
# Wrap tool call in parts array
parts = [{"type": "tool_call", "content": tool_call}]
content_array = [{"role": "assistant", "parts": parts}]
attributes = self._create_event_attributes(
conversation_id=conversation_id,
message_role="assistant",
)
# Store as JSON array directly without outer wrapper
attributes[GEN_AI_EVENT_CONTENT] = json.dumps(content_array, ensure_ascii=False)
span.span_instance.add_event(name=GEN_AI_ASSISTANT_MESSAGE_EVENT, attributes=attributes)

if _get_use_message_events():
# Original event-based implementation
json_content = json.dumps(content_array, ensure_ascii=False)
attributes = self._create_event_attributes(
conversation_id=conversation_id,
message_role="assistant",
)
# Store as JSON array directly without outer wrapper
attributes[GEN_AI_EVENT_CONTENT] = json_content
span.span_instance.add_event(name=GEN_AI_ASSISTANT_MESSAGE_EVENT, attributes=attributes)
else:
# New attribute-based implementation - tool calls are output messages
self._append_to_message_attribute(span, GEN_AI_OUTPUT_MESSAGES, content_array)

def _emit_tool_output_event(
self,
span: "AbstractSpan",
tool_output: Dict[str, Any],
conversation_id: Optional[str] = None,
) -> None:
"""Helper to emit a single tool output event."""
"""Helper to emit a single tool output event or attribute."""
# Wrap tool output in parts array
# Tool outputs are inputs TO the model (from tool execution), so use role "tool"
parts = [{"type": "tool_call_output", "content": tool_output}]
content_array = [{"role": "tool", "parts": parts}]
attributes = self._create_event_attributes(
conversation_id=conversation_id,
message_role="tool",
)
# Store as JSON array directly without outer wrapper
attributes[GEN_AI_EVENT_CONTENT] = json.dumps(content_array, ensure_ascii=False)
# Tool outputs are inputs to the model, so use input.messages event
span.span_instance.add_event(name=GEN_AI_USER_MESSAGE_EVENT, attributes=attributes)

if _get_use_message_events():
# Original event-based implementation
json_content = json.dumps(content_array, ensure_ascii=False)
attributes = self._create_event_attributes(
conversation_id=conversation_id,
message_role="tool",
)
# Store as JSON array directly without outer wrapper
attributes[GEN_AI_EVENT_CONTENT] = json_content
# Tool outputs are inputs to the model, so use input.messages event
span.span_instance.add_event(name=GEN_AI_USER_MESSAGE_EVENT, attributes=attributes)
else:
# New attribute-based implementation - tool outputs are input messages
self._append_to_message_attribute(span, GEN_AI_INPUT_MESSAGES, content_array)

def _add_tool_call_events( # pylint: disable=too-many-branches
self,
Expand Down Expand Up @@ -1486,10 +1570,13 @@ def start_responses_span(
# Build span name: agent case uses "invoke_agent", non-agent case uses "chat"
if assistant_name:
span_name = f"{SPAN_NAME_INVOKE_AGENT} {assistant_name}"
operation_name_value = OPERATION_NAME_INVOKE_AGENT
elif model:
span_name = f"{SPAN_NAME_CHAT} {model}"
operation_name_value = OPERATION_NAME_CHAT
else:
span_name = OperationName.RESPONSES.value
operation_name_value = OperationName.RESPONSES.value

span = start_span(
operation_name=OperationName.RESPONSES,
Expand All @@ -1504,7 +1591,7 @@ def start_responses_span(
# Set operation name attribute (start_span doesn't set this automatically)
self._set_attributes(
span,
(GEN_AI_OPERATION_NAME, OperationName.RESPONSES.value),
(GEN_AI_OPERATION_NAME, operation_name_value),
)

# Set response-specific attributes that start_span doesn't handle
Expand Down
33 changes: 33 additions & 0 deletions sdk/ai/azure-ai-projects/azure/ai/projects/telemetry/_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,10 @@
GEN_AI_SYSTEM_INSTRUCTION_EVENT = "gen_ai.system.instructions"
GEN_AI_AGENT_WORKFLOW_EVENT = "gen_ai.agent.workflow"

# Attribute names for messages (when USE_MESSAGE_EVENTS = False)
GEN_AI_INPUT_MESSAGES = "gen_ai.input.messages"
GEN_AI_OUTPUT_MESSAGES = "gen_ai.output.messages"

# Metric names
GEN_AI_CLIENT_OPERATION_DURATION = "gen_ai.client.operation.duration"
GEN_AI_CLIENT_TOKEN_USAGE = "gen_ai.client.token.usage"
Expand All @@ -107,6 +111,35 @@
SPAN_NAME_INVOKE_AGENT = "invoke_agent"
SPAN_NAME_CHAT = "chat"

# Operation names for gen_ai.operation.name attribute
OPERATION_NAME_INVOKE_AGENT = "invoke_agent"
OPERATION_NAME_CHAT = "chat"

# Configuration: Controls whether input/output messages are emitted as events or attributes
# Can be set at runtime for testing purposes (internal use only)
# Set to True for event-based, False for attribute-based (default)
_use_message_events = False


def _get_use_message_events() -> bool:
"""Get the current message tracing mode (events vs attributes). Internal use only.

:return: True if using events, False if using attributes
:rtype: bool
"""
return _use_message_events


def _set_use_message_events(use_events: bool) -> None:
"""
Set the message tracing mode at runtime. Internal use only.

:param use_events: True to use events (default), False to use attributes
:type use_events: bool
"""
global _use_message_events # pylint: disable=global-statement
_use_message_events = use_events


class OperationName(Enum):
CREATE_AGENT = "create_agent"
Expand Down
Loading
Loading