diff --git a/pyproject.toml b/pyproject.toml index e18f8805a..e20aa1143 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath" -version = "2.5.6" +version = "2.5.7" description = "Python SDK and CLI for UiPath Platform, enabling programmatic interaction with automation services, process management, and deployment tools." readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/src/uipath/tracing/_otel_exporters.py b/src/uipath/tracing/_otel_exporters.py index 34317f878..83fd6d27b 100644 --- a/src/uipath/tracing/_otel_exporters.py +++ b/src/uipath/tracing/_otel_exporters.py @@ -357,6 +357,13 @@ def _process_span_attributes(self, span_data: Dict[str, Any]) -> None: elif span_type == "toolCall": self._map_tool_call_attributes(attributes) + # Parse JSON-encoded strings that should be objects (avoids double-encoding) + # OTEL only accepts primitives, so agents serialize dicts to JSON strings. + # Detect and parse any string that looks like JSON object/array. + for key, value in attributes.items(): + if isinstance(value, str) and value and value[0] in "{[": + attributes[key] = _safe_parse_json(value) + # If attributes were a string (legacy path), serialize back # If dict (optimized path), leave as dict - caller will serialize once at the end if isinstance(attributes_val, str): diff --git a/tests/tracing/test_otel_exporters.py b/tests/tracing/test_otel_exporters.py index 07418c69f..1febaad15 100644 --- a/tests/tracing/test_otel_exporters.py +++ b/tests/tracing/test_otel_exporters.py @@ -556,6 +556,60 @@ def test_unknown_span_type_preserved(self): print("✓ UNKNOWN span preserved and processed correctly") print(f"✓ Final attributes keys: {list(attributes.keys())}") + def test_json_strings_parsed_to_objects(self): + """Test that JSON-encoded strings starting with { or [ are parsed to objects. + + OTEL only accepts primitives, so agents serialize dicts/lists to JSON strings. + The exporter should parse these back to objects before final serialization. + """ + span_data = { + "Id": "test-span-id", + "TraceId": "test-trace-id", + "ParentId": None, + "Name": "Test span", + "StartTime": "2025-01-01T00:00:00Z", + "EndTime": "2025-01-01T00:00:01Z", + "Attributes": { + "type": "agentRun", + "inputSchema": '{"type": "object", "properties": {}}', + "outputSchema": '{"type": "object", "properties": {"content": {"type": "string"}}}', + "settings": '{"maxTokens": 16384, "temperature": 0.0}', + "toolCalls": '[{"id": "call_123", "name": "test_tool"}]', + "regularString": "not json", + "emptyString": "", + }, + "Status": 1, + } + + self.exporter._process_span_attributes(span_data) + + attributes = span_data["Attributes"] + assert isinstance(attributes, dict) + + # JSON strings should be parsed to objects + input_schema = attributes["inputSchema"] + assert isinstance(input_schema, dict) + self.assertEqual(input_schema["type"], "object") + + output_schema = attributes["outputSchema"] + assert isinstance(output_schema, dict) + self.assertIn("content", output_schema["properties"]) + + settings = attributes["settings"] + assert isinstance(settings, dict) + self.assertEqual(settings["maxTokens"], 16384) + + tool_calls = attributes["toolCalls"] + assert isinstance(tool_calls, list) + self.assertEqual(tool_calls[0]["name"], "test_tool") + + # Non-JSON strings should remain as strings + self.assertIsInstance(attributes["regularString"], str) + self.assertEqual(attributes["regularString"], "not json") + + self.assertIsInstance(attributes["emptyString"], str) + self.assertEqual(attributes["emptyString"], "") + class TestSpanFiltering: """Tests for filtering spans marked with telemetry.filter=drop.""" diff --git a/uv.lock b/uv.lock index 080bf52bc..8761b7e1c 100644 --- a/uv.lock +++ b/uv.lock @@ -2486,7 +2486,7 @@ wheels = [ [[package]] name = "uipath" -version = "2.5.6" +version = "2.5.7" source = { editable = "." } dependencies = [ { name = "applicationinsights" },