Skip to content
Open
Changes from all 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
Original file line number Diff line number Diff line change
Expand Up @@ -292,8 +292,40 @@ def on_span_end(self, span):

# Extract completions and add directly to response span using OpenAI semantic conventions
if hasattr(response, 'output') and response.output:
for i, output in enumerate(response.output):
# Handle different output types
# Group outputs by type: separate tool calls from content
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Duplicate grouping logic for tool calls and content outputs appears multiple times. Consider extracting this into a helper to improve maintainability.

tool_calls = []
content_outputs = []

for output in response.output:
if hasattr(output, 'name'): # Tool call
tool_calls.append(output)
else: # Content output
content_outputs.append(output)

completion_index = 0

# Handle tool calls as a single completion with multiple tool_calls
if tool_calls:
otel_span.set_attribute(f"{SpanAttributes.LLM_COMPLETIONS}.{completion_index}.role", "assistant")
otel_span.set_attribute(f"{SpanAttributes.LLM_COMPLETIONS}.{completion_index}.content", "")
otel_span.set_attribute(f"{SpanAttributes.LLM_COMPLETIONS}.{completion_index}.finish_reason", "tool_calls")

Comment on lines +305 to +312
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Set top-level finish_reason once and avoid writing None values.

Write llm.response.finish_reason for dashboards expecting a single top-level value. Also guard against None to keep SDKs happy.

Apply this diff:

@@
-                        completion_index = 0
+                        completion_index = 0
+                        # Set top-level finish reason once
+                        _fr = getattr(response, "finish_reason", None)
+                        if _fr is not None:
+                            otel_span.set_attribute(SpanAttributes.LLM_RESPONSE_FINISH_REASON, _fr)
+                        elif tool_calls:
+                            otel_span.set_attribute(SpanAttributes.LLM_RESPONSE_FINISH_REASON, "tool_calls")
@@
-                                    # Add finish reason if available
-                                    if hasattr(response, 'finish_reason'):
-                                        otel_span.set_attribute(
-                                            f"{SpanAttributes.LLM_COMPLETIONS}.{completion_index}.finish_reason", response.finish_reason)
+                                    # Add finish reason if available
+                                    if _fr is not None:
+                                        otel_span.set_attribute(
+                                            f"{SpanAttributes.LLM_COMPLETIONS}.{completion_index}.finish_reason", _fr)
@@
-                                # Add finish reason if available
-                                if hasattr(response, 'finish_reason'):
-                                    otel_span.set_attribute(
-                                        f"{SpanAttributes.LLM_COMPLETIONS}.{completion_index}.finish_reason", response.finish_reason)
+                                # Add finish reason if available
+                                if _fr is not None:
+                                    otel_span.set_attribute(
+                                        f"{SpanAttributes.LLM_COMPLETIONS}.{completion_index}.finish_reason", _fr)

Also applies to: 343-347, 357-361

🤖 Prompt for AI Agents
In
packages/opentelemetry-instrumentation-openai-agents/opentelemetry/instrumentation/openai_agents/_hooks.py
around lines 305-312 (and similarly at 343-347 and 357-361), the span is setting
per-completion finish_reason and may write None values; instead set a single
top-level llm.response.finish_reason attribute once (e.g., "tool_calls") for
dashboards expecting a single value, and when setting any span attribute check
the value is not None before calling otel_span.set_attribute so we never write
None into attributes.

for tool_index, output in enumerate(tool_calls):
tool_name = getattr(output, 'name', 'unknown_tool')
arguments = getattr(output, 'arguments', '{}')
tool_call_id = getattr(output, 'call_id', f"call_{tool_index}")

otel_span.set_attribute(
f"{SpanAttributes.LLM_COMPLETIONS}.{completion_index}.tool_calls.{tool_index}.name", tool_name)
otel_span.set_attribute(
f"{SpanAttributes.LLM_COMPLETIONS}.{completion_index}.tool_calls.{tool_index}.arguments", arguments)
otel_span.set_attribute(
f"{SpanAttributes.LLM_COMPLETIONS}.{completion_index}.tool_calls.{tool_index}.id", tool_call_id)
Comment on lines +313 to +323
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Serialize tool call arguments to JSON string (OTel attribute types must be scalar/array).

arguments may be a dict/object; setting it directly can drop the attr or coerce inconsistently across SDKs. Emit a compact JSON string.

Apply these diffs:

@@
-                                arguments = getattr(output, 'arguments', '{}')
+                                arguments = getattr(output, 'arguments', None)
+                                if not isinstance(arguments, str):
+                                    try:
+                                        arguments = json.dumps(arguments, separators=(',', ':'), ensure_ascii=False)
+                                    except TypeError:
+                                        arguments = "{}" if arguments is None else str(arguments)
@@
-                                otel_span.set_attribute(
-                                    f"{SpanAttributes.LLM_COMPLETIONS}.{completion_index}.tool_calls.{tool_index}.arguments", arguments)
+                                otel_span.set_attribute(
+                                    f"{SpanAttributes.LLM_COMPLETIONS}.{completion_index}.tool_calls.{tool_index}.arguments", arguments)
@@
-                                arguments = getattr(output, 'arguments', '{}')
+                                arguments = getattr(output, 'arguments', None)
+                                if not isinstance(arguments, str):
+                                    try:
+                                        arguments = json.dumps(arguments, separators=(',', ':'), ensure_ascii=False)
+                                    except TypeError:
+                                        arguments = "{}" if arguments is None else str(arguments)
@@
-                                otel_span.set_attribute(
-                                    f"{SpanAttributes.LLM_COMPLETIONS}.{completion_index}.tool_calls.{tool_index}.arguments", arguments)
+                                otel_span.set_attribute(
+                                    f"{SpanAttributes.LLM_COMPLETIONS}.{completion_index}.tool_calls.{tool_index}.arguments", arguments)

Also applies to: 441-450

🤖 Prompt for AI Agents
In
packages/opentelemetry-instrumentation-openai-agents/opentelemetry/instrumentation/openai_agents/_hooks.py
around lines 313-323 (and also update the analogous block at lines ~441-450),
the tool call "arguments" value may be a non-scalar (dict/object) which can be
dropped or coerced by OTel SDKs; convert/serialize arguments to a compact JSON
string before calling otel_span.set_attribute. Implement: obtain the arguments
value, if it's not already a string, json.dumps(arguments, separators=(",",
":"), ensure_ascii=False), catch serialization exceptions and fall back to
str(arguments), then pass that string to otel_span.set_attribute for the
arguments attribute; do the same change in the other block at lines 441-450.


completion_index += 1

# Handle content outputs as separate completions
for output in content_outputs:
if hasattr(output, 'content') and output.content:
# Text message with content array (ResponseOutputMessage)
content_text = ""
Expand All @@ -303,39 +335,31 @@ def on_span_end(self, span):

if content_text:
otel_span.set_attribute(
f"{SpanAttributes.LLM_COMPLETIONS}.{i}.content", content_text)
f"{SpanAttributes.LLM_COMPLETIONS}.{completion_index}.content", content_text)
otel_span.set_attribute(
f"{SpanAttributes.LLM_COMPLETIONS}.{i}.role", getattr(
f"{SpanAttributes.LLM_COMPLETIONS}.{completion_index}.role", getattr(
output, 'role', 'assistant'))

elif hasattr(output, 'name'):
# Function/tool call (ResponseFunctionToolCall) - use OpenAI tool call format
tool_name = getattr(output, 'name', 'unknown_tool')
arguments = getattr(output, 'arguments', '{}')
tool_call_id = getattr(output, 'call_id', f"call_{i}")

# Set completion with tool call following OpenAI format
otel_span.set_attribute(f"{SpanAttributes.LLM_COMPLETIONS}.{i}.role", "assistant")
otel_span.set_attribute(
f"{SpanAttributes.LLM_COMPLETIONS}.{i}.finish_reason", "tool_calls")
otel_span.set_attribute(
f"{SpanAttributes.LLM_COMPLETIONS}.{i}.tool_calls.0.name", tool_name)
otel_span.set_attribute(
f"{SpanAttributes.LLM_COMPLETIONS}.{i}.tool_calls.0.arguments", arguments)
otel_span.set_attribute(
f"{SpanAttributes.LLM_COMPLETIONS}.{i}.tool_calls.0.id", tool_call_id)

# Add finish reason if available
if hasattr(response, 'finish_reason'):
otel_span.set_attribute(
f"{SpanAttributes.LLM_COMPLETIONS}.{completion_index}.finish_reason", response.finish_reason)

completion_index += 1

elif hasattr(output, 'text'):
# Direct text content
otel_span.set_attribute(f"{SpanAttributes.LLM_COMPLETIONS}.{i}.content", output.text)
otel_span.set_attribute(f"{SpanAttributes.LLM_COMPLETIONS}.{completion_index}.content", output.text)
otel_span.set_attribute(
f"{SpanAttributes.LLM_COMPLETIONS}.{i}.role", getattr(
f"{SpanAttributes.LLM_COMPLETIONS}.{completion_index}.role", getattr(
output, 'role', 'assistant'))

# Add finish reason if available (for non-tool-call cases)
if hasattr(response, 'finish_reason') and not hasattr(output, 'name'):
otel_span.set_attribute(
f"{SpanAttributes.LLM_COMPLETIONS}.{i}.finish_reason", response.finish_reason)

# Add finish reason if available
if hasattr(response, 'finish_reason'):
otel_span.set_attribute(
f"{SpanAttributes.LLM_COMPLETIONS}.{completion_index}.finish_reason", response.finish_reason)

completion_index += 1

# Extract usage data and add directly to response span
if hasattr(response, 'usage') and response.usage:
Expand Down Expand Up @@ -395,8 +419,40 @@ def on_span_end(self, span):

# Extract completions and add directly to response span using OpenAI semantic conventions
if hasattr(response, 'output') and response.output:
for i, output in enumerate(response.output):
# Handle different output types
# Group outputs by type: separate tool calls from content
tool_calls = []
content_outputs = []

for output in response.output:
if hasattr(output, 'name'): # Tool call
tool_calls.append(output)
else: # Content output
content_outputs.append(output)

completion_index = 0

# Handle tool calls as a single completion with multiple tool_calls
if tool_calls:
otel_span.set_attribute(f"{SpanAttributes.LLM_COMPLETIONS}.{completion_index}.role", "assistant")
otel_span.set_attribute(f"{SpanAttributes.LLM_COMPLETIONS}.{completion_index}.content", "")
otel_span.set_attribute(f"{SpanAttributes.LLM_COMPLETIONS}.{completion_index}.finish_reason", "tool_calls")

Comment on lines +432 to +439
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Mirror finish_reason fixes in legacy path.

Ensure top-level finish_reason is set and avoid None in nested attrs here too.

Apply this diff:

@@
-                        completion_index = 0
+                        completion_index = 0
+                        _fr = getattr(response, "finish_reason", None)
+                        if _fr is not None:
+                            otel_span.set_attribute(SpanAttributes.LLM_RESPONSE_FINISH_REASON, _fr)
+                        elif tool_calls:
+                            otel_span.set_attribute(SpanAttributes.LLM_RESPONSE_FINISH_REASON, "tool_calls")
@@
-                                    # Add finish reason if available
-                                    if hasattr(response, 'finish_reason'):
-                                        otel_span.set_attribute(
-                                            f"{SpanAttributes.LLM_COMPLETIONS}.{completion_index}.finish_reason", response.finish_reason)
+                                    # Add finish reason if available
+                                    if _fr is not None:
+                                        otel_span.set_attribute(
+                                            f"{SpanAttributes.LLM_COMPLETIONS}.{completion_index}.finish_reason", _fr)
@@
-                                # Add finish reason if available
-                                if hasattr(response, 'finish_reason'):
-                                    otel_span.set_attribute(
-                                        f"{SpanAttributes.LLM_COMPLETIONS}.{completion_index}.finish_reason", response.finish_reason)
+                                # Add finish reason if available
+                                if _fr is not None:
+                                    otel_span.set_attribute(
+                                        f"{SpanAttributes.LLM_COMPLETIONS}.{completion_index}.finish_reason", _fr)

Also applies to: 471-474, 485-487

🤖 Prompt for AI Agents
In
packages/opentelemetry-instrumentation-openai-agents/opentelemetry/instrumentation/openai_agents/_hooks.py
around lines 432-439 (and similarly mirror the same changes at 471-474 and
485-487): the block that treats tool_calls as a single completion sets nested
attributes but does not set the top-level finish_reason and may write None into
nested attributes; update this legacy-path block to also set the top-level
finish_reason to "tool_calls" and ensure nested attribute values are non-None
(use empty string or default values instead of None) when calling
otel_span.set_attribute so that both top-level and nested completion attrs are
populated consistently.

for tool_index, output in enumerate(tool_calls):
tool_name = getattr(output, 'name', 'unknown_tool')
arguments = getattr(output, 'arguments', '{}')
tool_call_id = getattr(output, 'call_id', f"call_{tool_index}")

otel_span.set_attribute(
f"{SpanAttributes.LLM_COMPLETIONS}.{completion_index}.tool_calls.{tool_index}.name", tool_name)
otel_span.set_attribute(
f"{SpanAttributes.LLM_COMPLETIONS}.{completion_index}.tool_calls.{tool_index}.arguments", arguments)
otel_span.set_attribute(
f"{SpanAttributes.LLM_COMPLETIONS}.{completion_index}.tool_calls.{tool_index}.id", tool_call_id)

completion_index += 1

# Handle content outputs as separate completions
for output in content_outputs:
if hasattr(output, 'content') and output.content:
# Text message with content array (ResponseOutputMessage)
content_text = ""
Expand All @@ -406,39 +462,31 @@ def on_span_end(self, span):

if content_text:
otel_span.set_attribute(
f"{SpanAttributes.LLM_COMPLETIONS}.{i}.content", content_text)
f"{SpanAttributes.LLM_COMPLETIONS}.{completion_index}.content", content_text)
otel_span.set_attribute(
f"{SpanAttributes.LLM_COMPLETIONS}.{i}.role", getattr(
f"{SpanAttributes.LLM_COMPLETIONS}.{completion_index}.role", getattr(
output, 'role', 'assistant'))

elif hasattr(output, 'name'):
# Function/tool call (ResponseFunctionToolCall) - use OpenAI tool call format
tool_name = getattr(output, 'name', 'unknown_tool')
arguments = getattr(output, 'arguments', '{}')
tool_call_id = getattr(output, 'call_id', f"call_{i}")

# Set completion with tool call following OpenAI format
otel_span.set_attribute(f"{SpanAttributes.LLM_COMPLETIONS}.{i}.role", "assistant")
otel_span.set_attribute(
f"{SpanAttributes.LLM_COMPLETIONS}.{i}.finish_reason", "tool_calls")
otel_span.set_attribute(
f"{SpanAttributes.LLM_COMPLETIONS}.{i}.tool_calls.0.name", tool_name)
otel_span.set_attribute(
f"{SpanAttributes.LLM_COMPLETIONS}.{i}.tool_calls.0.arguments", arguments)
otel_span.set_attribute(
f"{SpanAttributes.LLM_COMPLETIONS}.{i}.tool_calls.0.id", tool_call_id)

# Add finish reason if available
if hasattr(response, 'finish_reason'):
otel_span.set_attribute(
f"{SpanAttributes.LLM_COMPLETIONS}.{completion_index}.finish_reason", response.finish_reason)

completion_index += 1

elif hasattr(output, 'text'):
# Direct text content
otel_span.set_attribute(f"{SpanAttributes.LLM_COMPLETIONS}.{i}.content", output.text)
otel_span.set_attribute(f"{SpanAttributes.LLM_COMPLETIONS}.{completion_index}.content", output.text)
otel_span.set_attribute(
f"{SpanAttributes.LLM_COMPLETIONS}.{i}.role", getattr(
f"{SpanAttributes.LLM_COMPLETIONS}.{completion_index}.role", getattr(
output, 'role', 'assistant'))

# Add finish reason if available (for non-tool-call cases)
if hasattr(response, 'finish_reason') and not hasattr(output, 'name'):
otel_span.set_attribute(
f"{SpanAttributes.LLM_COMPLETIONS}.{i}.finish_reason", response.finish_reason)

# Add finish reason if available
if hasattr(response, 'finish_reason'):
otel_span.set_attribute(
f"{SpanAttributes.LLM_COMPLETIONS}.{completion_index}.finish_reason", response.finish_reason)

completion_index += 1

# Extract usage data and add directly to response span
if hasattr(response, 'usage') and response.usage:
Expand Down
Loading