Skip to content

Commit f47405a

Browse files
fix: normalize nil UUID ProcessKey to null for debug runs (#1376)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent c2308d0 commit f47405a

File tree

2 files changed

+62
-42
lines changed

2 files changed

+62
-42
lines changed

src/uipath/tracing/_otel_exporters.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,12 @@
1616

1717
logger = logging.getLogger(__name__)
1818

19+
_NIL_UUID = "00000000-0000-0000-0000-000000000000"
20+
21+
22+
def _normalize_process_key(value: Optional[str]) -> Optional[str]:
23+
return None if not value or value == _NIL_UUID else value
24+
1925

2026
class SpanStatus:
2127
"""Span status values matching LLMOps StatusEnum."""
@@ -149,6 +155,10 @@ def export(self, spans: Sequence[ReadableSpan]) -> SpanExportResult:
149155

150156
# Process spans in-place - work directly with dict
151157
for span_data in span_list:
158+
if "ProcessKey" in span_data:
159+
span_data["ProcessKey"] = _normalize_process_key(
160+
span_data["ProcessKey"]
161+
)
152162
self._process_span_attributes(span_data)
153163

154164
# Serialize attributes once at the very end
@@ -189,6 +199,8 @@ def upsert_span(
189199

190200
url = self._build_url([span_data])
191201

202+
if "ProcessKey" in span_data:
203+
span_data["ProcessKey"] = _normalize_process_key(span_data["ProcessKey"])
192204
self._process_span_attributes(span_data)
193205

194206
# Apply status override after processing (which may set status from error)

tests/tracing/test_otel_exporters.py

Lines changed: 50 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -554,67 +554,29 @@ def test_unknown_span_type_preserved(self):
554554
"SpanType": "OpenTelemetry",
555555
}
556556

557-
print("\n=== Testing UNKNOWN span type preservation ===")
558-
print(f"Initial SpanType: {span_data['SpanType']}")
559-
attributes_before = span_data["Attributes"]
560-
assert isinstance(attributes_before, dict)
561-
print(
562-
f"openinference.span.kind: {attributes_before['openinference.span.kind']}"
563-
)
564-
print(f"Attributes type before: {type(attributes_before)}")
565-
566-
# Process the span
567557
self.exporter._process_span_attributes(span_data)
568558

569-
print(f"SpanType after processing: {span_data['SpanType']}")
570-
print(f"Attributes type after: {type(span_data['Attributes'])}")
571-
572-
# Verify span is processed correctly
573-
self.assertEqual(
574-
span_data["SpanType"],
575-
"UNKNOWN",
576-
"SpanType should be mapped to UNKNOWN from openinference.span.kind",
577-
)
578-
self.assertIn("Attributes", span_data, "Attributes should still be present")
559+
self.assertEqual(span_data["SpanType"], "UNKNOWN")
560+
self.assertIn("Attributes", span_data)
579561

580-
# When input is dict, output stays as dict (optimized path)
581562
attributes = span_data["Attributes"]
582563
assert isinstance(attributes, dict)
583-
self.assertIsInstance(
584-
attributes, dict, "Attributes should remain as dict in optimized path"
585-
)
586564

587-
# Basic attribute mapping should still work
588-
self.assertIn(
589-
"input",
590-
attributes,
591-
"input.value should be mapped to input by ATTRIBUTE_MAPPING",
592-
)
593-
self.assertIn(
594-
"output",
595-
attributes,
596-
"output.value should be mapped to output by ATTRIBUTE_MAPPING",
597-
)
565+
self.assertIn("input", attributes)
566+
self.assertIn("output", attributes)
598567

599-
# Verify mime types are preserved
600568
self.assertEqual(attributes["input.mime_type"], "application/json")
601569
self.assertEqual(attributes["output.mime_type"], "application/json")
602570

603-
# Verify parsed values
604571
input_val = attributes["input"]
605572
assert isinstance(input_val, dict)
606-
self.assertIsInstance(input_val, dict, "input should be parsed from JSON")
607573
self.assertIn("content", input_val)
608574

609575
output_val = attributes["output"]
610576
assert isinstance(output_val, dict)
611-
self.assertIsInstance(output_val, dict, "output should be parsed from JSON")
612577
self.assertEqual(output_val["label"], "security")
613578
self.assertEqual(output_val["confidence"], 0.95)
614579

615-
print("✓ UNKNOWN span preserved and processed correctly")
616-
print(f"✓ Final attributes keys: {list(attributes.keys())}")
617-
618580
def test_json_strings_parsed_to_objects(self):
619581
"""Test that JSON-encoded strings starting with { or [ are parsed to objects.
620582
@@ -758,5 +720,51 @@ def test_upsert_span_failure_retries(self, exporter_with_mocks, mock_span):
758720
assert exporter_with_mocks.http_client.post.call_count == 4 # max_retries=4
759721

760722

723+
class TestNilUuidProcessKey:
724+
"""ProcessKey nil UUID normalization during export."""
725+
726+
def _export_with_process_key(self, mock_env_vars, mock_span, process_key):
727+
"""Export a span with the given ProcessKey and return the exported payload."""
728+
mock_uipath_span = MagicMock()
729+
mock_uipath_span.to_dict.return_value = {
730+
"TraceId": "test-trace-id",
731+
"Id": "span-id",
732+
"ProcessKey": process_key,
733+
"Attributes": {},
734+
}
735+
736+
with (
737+
patch("uipath.tracing._otel_exporters.httpx.Client"),
738+
patch(
739+
"uipath.tracing._otel_exporters._SpanUtils.otel_span_to_uipath_span",
740+
return_value=mock_uipath_span,
741+
),
742+
):
743+
exporter = LlmOpsHttpExporter()
744+
mock_response = MagicMock()
745+
mock_response.status_code = 200
746+
exporter.http_client.post.return_value = mock_response # type: ignore
747+
exporter._build_url = MagicMock(return_value="http://test/api") # type: ignore
748+
749+
exporter.export([mock_span])
750+
751+
return exporter.http_client.post.call_args.kwargs["json"][0] # type: ignore
752+
753+
def test_nil_uuid_normalized_to_none(self, mock_env_vars, mock_span):
754+
payload = self._export_with_process_key(
755+
mock_env_vars, mock_span, "00000000-0000-0000-0000-000000000000"
756+
)
757+
assert payload["ProcessKey"] is None
758+
759+
def test_valid_uuid_preserved(self, mock_env_vars, mock_span):
760+
real_key = "65965c09-87e3-4fa3-a7be-3fdb3955bd47"
761+
payload = self._export_with_process_key(mock_env_vars, mock_span, real_key)
762+
assert payload["ProcessKey"] == real_key
763+
764+
def test_none_stays_none(self, mock_env_vars, mock_span):
765+
payload = self._export_with_process_key(mock_env_vars, mock_span, None)
766+
assert payload["ProcessKey"] is None
767+
768+
761769
if __name__ == "__main__":
762770
unittest.main()

0 commit comments

Comments
 (0)