Skip to content
Draft
Show file tree
Hide file tree
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
40 changes: 24 additions & 16 deletions packages/uipath/src/uipath/tracing/_otel_exporters.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,27 +61,35 @@
messages[index] = {}
current: Any = messages[index]

# Traverse parts except the last one
# Reconstruct nested containers. A digit segment indexes a list;
# any other segment keys a dict. Each child's container type is
# decided by the NEXT segment (a following digit => the child is a
# list), so e.g. `tool_calls.0.tool_call...` rebuilds tool_calls
# as a list rather than a {"0": ...} dict the consumer skips.
parts_len = len(parts)
for i in range(1, parts_len - 1):
for i in range(1, parts_len):
part = parts[i]
key_part: str | int = part
if part.isdigit() and (
i + 2 < parts_len and parts[i + 2].isdigit()
):
key_part = int(part)

if isinstance(current, dict):
if key_part not in current:
current[key_part] = {}
key_part: str | int = int(part) if part.isdigit() else part
is_last = i == parts_len - 1
child: Any = (
value if is_last else ([] if parts[i + 1].isdigit() else {})

Check warning on line 75 in packages/uipath/src/uipath/tracing/_otel_exporters.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Extract this nested conditional expression into an independent statement.

See more on https://sonarcloud.io/project/issues?id=UiPath_uipath-python&issues=AZ8SzEaIgfx-sGuX9vW0&open=AZ8SzEaIgfx-sGuX9vW0&pullRequest=1771
)

if isinstance(current, list) and isinstance(key_part, int):
while len(current) <= key_part:
current.append(None)
if is_last or current[key_part] is None:
current[key_part] = child
current = current[key_part]
elif isinstance(current, list) and isinstance(key_part, int):
if key_part >= len(current):
current.append({})
elif isinstance(current, dict):
if (
is_last
or key_part not in current
or current[key_part] is None
):
current[key_part] = child
current = current[key_part]

current[parts[-1]] = value

# Convert dict to list, ordered by index, avoid sorted() if we can use range
if not messages:
return []
Expand Down
54 changes: 54 additions & 0 deletions packages/uipath/tests/tracing/test_otel_exporters.py
Original file line number Diff line number Diff line change
Expand Up @@ -569,6 +569,60 @@ def test_llm_span_mapping_consistency(self):
self.assertEqual(usage["completionTokens"], 66)
self.assertEqual(usage["totalTokens"], 285)

def test_llm_span_tool_calls_reconstructed_as_list(self):
"""An LLM span's flattened tool_calls must rebuild into a list.

OpenInference flattens a tool call as
``llm.output_messages.0.message.tool_calls.0.tool_call.function.name``.
The reconstruction must turn the ``tool_calls`` index segment into a
list element, not a ``{"0": ...}`` dict, so the toolCalls mapping (which
iterates the value as a list) actually picks the call up.
"""
span_data = {
"Id": "1f1a4d8e-2b3c-4d5e-8f90-112233445566",
"TraceId": "2f1a4d8e-2b3c-4d5e-8f90-112233445566",
"ParentId": "3f1a4d8e-2b3c-4d5e-8f90-112233445566",
"Name": "UiPathChat",
"StartTime": "2025-09-18T15:25:36.486Z",
"EndTime": "2025-09-18T15:25:37.720Z",
"Attributes": {
"input.value": '{"messages": []}',
"output.value": '{"generations": []}',
"llm.model_name": "gpt-4o-mini-2024-07-18",
"openinference.span.kind": "LLM",
"llm.output_messages.0.message.role": "assistant",
"llm.output_messages.0.message.tool_calls.0.tool_call.id": "call_abc",
"llm.output_messages.0.message.tool_calls.0.tool_call.function.name": "get_weather",
"llm.output_messages.0.message.tool_calls.0.tool_call.function.arguments": '{"city": "NYC"}',
"llm.output_messages.0.message.tool_calls.1.tool_call.id": "call_def",
"llm.output_messages.0.message.tool_calls.1.tool_call.function.name": "get_time",
"llm.output_messages.0.message.tool_calls.1.tool_call.function.arguments": '{"tz": "EST"}',
},
"Status": 1,
"SpanType": "OpenTelemetry",
"ReferenceId": None,
}

self.exporter._process_span_attributes(span_data)

attributes = span_data["Attributes"]
assert isinstance(attributes, dict)
self.assertEqual(span_data["SpanType"], "completion")

# tool_calls reconstructed as a list, not a {"0": ...}/{"1": ...} dict.
message = attributes["output"][0]["message"]
self.assertIsInstance(message["tool_calls"], list)
self.assertEqual(len(message["tool_calls"]), 2)

# The toolCalls mapping picks both calls up, in order.
self.assertEqual(
attributes["toolCalls"],
[
{"id": "call_abc", "name": "get_weather", "arguments": {"city": "NYC"}},
{"id": "call_def", "name": "get_time", "arguments": {"tz": "EST"}},
],
)

def test_unknown_span_type_preserved(self):
"""
Test that spans with UNKNOWN or unrecognized openinference.span.kind
Expand Down
Loading