diff --git a/src/galileo/logger/logger.py b/src/galileo/logger/logger.py index 4a8ec907..c22a33e5 100644 --- a/src/galileo/logger/logger.py +++ b/src/galileo/logger/logger.py @@ -864,7 +864,7 @@ def add_single_llm_span_trace( name: Optional[str] = None, created_at: Optional[datetime] = None, duration_ns: Optional[int] = None, - metadata: Optional[dict[str, str]] = None, + metadata: Optional[dict[str, MetadataValue]] = None, tags: Optional[list[str]] = None, num_input_tokens: Optional[int] = None, num_output_tokens: Optional[int] = None, @@ -874,7 +874,7 @@ def add_single_llm_span_trace( time_to_first_token_ns: Optional[int] = None, dataset_input: Optional[str] = None, dataset_output: Optional[str] = None, - dataset_metadata: Optional[dict[str, str]] = None, + dataset_metadata: Optional[dict[str, MetadataValue]] = None, span_step_number: Optional[int] = None, ) -> Trace: """ @@ -958,6 +958,12 @@ def add_single_llm_span_trace( Trace The created trace. """ + # Auto-convert non-string metadata values to strings + if metadata: + metadata = {k: GalileoLogger._convert_metadata_value(v) for k, v in metadata.items()} + if dataset_metadata: + dataset_metadata = {k: GalileoLogger._convert_metadata_value(v) for k, v in dataset_metadata.items()} + trace = super().add_single_llm_span_trace( input=input, output=output, @@ -1003,7 +1009,7 @@ def add_llm_span( name: Optional[str] = None, created_at: Optional[datetime] = None, duration_ns: Optional[int] = None, - metadata: Optional[dict[str, str]] = None, + metadata: Optional[dict[str, MetadataValue]] = None, tags: Optional[list[str]] = None, num_input_tokens: Optional[int] = None, num_output_tokens: Optional[int] = None, @@ -1087,6 +1093,10 @@ def add_llm_span( LlmSpan The created span. """ + # Auto-convert non-string metadata values to strings + if metadata: + metadata = {k: GalileoLogger._convert_metadata_value(v) for k, v in metadata.items()} + kwargs = { "input": input, "output": output, @@ -1128,7 +1138,7 @@ def add_retriever_span( name: Optional[str] = None, duration_ns: Optional[int] = None, created_at: Optional[datetime] = None, - metadata: Optional[dict[str, str]] = None, + metadata: Optional[dict[str, MetadataValue]] = None, tags: Optional[list[str]] = None, status_code: Optional[int] = None, step_number: Optional[int] = None, @@ -1167,6 +1177,10 @@ def add_retriever_span( documents = convert_to_documents(output, "output") redacted_documents = convert_to_documents(redacted_output, "redacted_output") + # Auto-convert non-string metadata values to strings + if metadata: + metadata = {k: GalileoLogger._convert_metadata_value(v) for k, v in metadata.items()} + kwargs = { "input": input, "documents": documents, @@ -1199,7 +1213,7 @@ def add_tool_span( name: Optional[str] = None, duration_ns: Optional[int] = None, created_at: Optional[datetime] = None, - metadata: Optional[dict[str, str]] = None, + metadata: Optional[dict[str, MetadataValue]] = None, tags: Optional[list[str]] = None, status_code: Optional[int] = None, tool_call_id: Optional[str] = None, @@ -1251,6 +1265,10 @@ def add_tool_span( ToolSpan The created span. """ + # Auto-convert non-string metadata values to strings + if metadata: + metadata = {k: GalileoLogger._convert_metadata_value(v) for k, v in metadata.items()} + kwargs = { "input": input, "redacted_input": redacted_input, @@ -1282,7 +1300,7 @@ def add_protect_span( response: Optional[Response] = None, redacted_response: Optional[Response] = None, created_at: Optional[datetime] = None, - metadata: Optional[dict[str, str]] = None, + metadata: Optional[dict[str, MetadataValue]] = None, tags: Optional[list[str]] = None, status_code: Optional[int] = None, step_number: Optional[int] = None, @@ -1325,6 +1343,10 @@ def add_protect_span( ToolSpan The created Protect tool span. """ + # Auto-convert non-string metadata values to strings + if metadata: + metadata = {k: GalileoLogger._convert_metadata_value(v) for k, v in metadata.items()} + kwargs = { "input": json.dumps(payload.model_dump(mode="json")), "redacted_input": json.dumps(redacted_payload.model_dump(mode="json")) if redacted_payload else None, @@ -1359,7 +1381,7 @@ def add_workflow_span( name: Optional[str] = None, duration_ns: Optional[int] = None, created_at: Optional[datetime] = None, - metadata: Optional[dict[str, str]] = None, + metadata: Optional[dict[str, MetadataValue]] = None, tags: Optional[list[str]] = None, step_number: Optional[int] = None, status_code: Optional[int] = None, @@ -1408,6 +1430,10 @@ def add_workflow_span( WorkflowSpan The created span. """ + # Auto-convert non-string metadata values to strings + if metadata: + metadata = {k: GalileoLogger._convert_metadata_value(v) for k, v in metadata.items()} + kwargs = { "input": input, "redacted_input": redacted_input, @@ -1442,7 +1468,7 @@ def add_agent_span( name: Optional[str] = None, duration_ns: Optional[int] = None, created_at: Optional[datetime] = None, - metadata: Optional[dict[str, str]] = None, + metadata: Optional[dict[str, MetadataValue]] = None, tags: Optional[list[str]] = None, agent_type: Optional[AgentType] = None, step_number: Optional[int] = None, @@ -1494,6 +1520,10 @@ def add_agent_span( AgentSpan The created span. """ + # Auto-convert non-string metadata values to strings + if metadata: + metadata = {k: GalileoLogger._convert_metadata_value(v) for k, v in metadata.items()} + kwargs = { "input": input, "redacted_input": redacted_input, diff --git a/tests/test_logger_batch.py b/tests/test_logger_batch.py index 5fa7e17f..138d4fac 100644 --- a/tests/test_logger_batch.py +++ b/tests/test_logger_batch.py @@ -1754,6 +1754,126 @@ def test_start_trace_auto_conversion( assert getattr(payload_trace, attr) == expected_value, f"payload.{attr} mismatch" +@pytest.mark.parametrize( + "span_method,span_kwargs,expected_metadata", + [ + pytest.param( + "add_llm_span", + {"input": "prompt", "output": "response", "model": "gpt-4o"}, + {"intMeta": "1", "boolMeta": "True", "ratio": "3.14", "name": "test"}, + id="llm_span", + ), + pytest.param( + "add_retriever_span", + {"input": "query", "output": [Document(content="doc1")]}, + {"intMeta": "1", "boolMeta": "True", "ratio": "3.14", "name": "test"}, + id="retriever_span", + ), + pytest.param( + "add_tool_span", + {"input": "tool input"}, + {"intMeta": "1", "boolMeta": "True", "ratio": "3.14", "name": "test"}, + id="tool_span", + ), + pytest.param( + "add_workflow_span", + {"input": "workflow input"}, + {"intMeta": "1", "boolMeta": "True", "ratio": "3.14", "name": "test"}, + id="workflow_span", + ), + pytest.param( + "add_agent_span", + {"input": "agent input"}, + {"intMeta": "1", "boolMeta": "True", "ratio": "3.14", "name": "test"}, + id="agent_span", + ), + ], +) +def test_span_metadata_auto_conversion( + span_method: str, + span_kwargs: dict, + expected_metadata: dict, +) -> None: + """Test that span methods auto-convert non-string metadata values to strings. + + Regression test for Shortcut #54947: passing int/bool metadata to span methods + caused a silent Pydantic ValidationError, dropping the span entirely. + """ + # Given: a logger with an active trace and non-string metadata values + ingestion_hook = Mock() + logger = GalileoLogger(project="my_project", log_stream="my_log_stream", ingestion_hook=ingestion_hook) + logger.start_trace(input="test input") + + non_string_metadata = {"intMeta": 1, "boolMeta": True, "ratio": 3.14, "name": "test"} + + # When: adding a span with non-string metadata + method = getattr(logger, span_method) + span = method(**span_kwargs, metadata=non_string_metadata) + + # Then: the span is created successfully with metadata converted to strings + assert span is not None, f"{span_method} returned None — metadata conversion failed" + assert span.user_metadata == expected_metadata, f"{span_method} metadata mismatch" + + +@pytest.mark.parametrize( + "span_method,span_kwargs", + [ + pytest.param("add_llm_span", {"input": "prompt", "output": "response", "model": "gpt-4o"}, id="llm_span"), + pytest.param("add_tool_span", {"input": "tool input"}, id="tool_span"), + pytest.param("add_workflow_span", {"input": "workflow input"}, id="workflow_span"), + pytest.param("add_agent_span", {"input": "agent input"}, id="agent_span"), + ], +) +def test_span_metadata_none_values_converted( + span_method: str, + span_kwargs: dict, +) -> None: + """Test that None metadata values are converted to the string 'None'.""" + # Given: a logger with an active trace and metadata containing None values + ingestion_hook = Mock() + logger = GalileoLogger(project="my_project", log_stream="my_log_stream", ingestion_hook=ingestion_hook) + logger.start_trace(input="test input") + + metadata_with_none = {"key1": "value1", "key2": None, "key3": 42} + + # When: adding a span with metadata containing None + method = getattr(logger, span_method) + span = method(**span_kwargs, metadata=metadata_with_none) + + # Then: the span is created with None converted to string "None" + assert span is not None, f"{span_method} returned None" + assert span.user_metadata["key1"] == "value1" + assert span.user_metadata["key2"] == "None" + assert span.user_metadata["key3"] == "42" + + +def test_add_single_llm_span_trace_metadata_auto_conversion() -> None: + """Test that add_single_llm_span_trace auto-converts non-string metadata and dataset_metadata.""" + # Given: non-string metadata and dataset_metadata values + ingestion_hook = Mock() + logger = GalileoLogger(project="my_project", log_stream="my_log_stream", ingestion_hook=ingestion_hook) + + non_string_metadata = {"intMeta": 1, "boolMeta": True, "strMeta": "physics"} + non_string_dataset_metadata = {"enabled": True, "count": 42} + + # When: creating a single LLM span trace with non-string metadata + trace = logger.add_single_llm_span_trace( + input="prompt", + output="response", + model="gpt-4o", + metadata=non_string_metadata, + dataset_metadata=non_string_dataset_metadata, + ) + + # Then: both metadata dicts are converted to strings + assert trace is not None, "add_single_llm_span_trace returned None" + assert trace.user_metadata == {"intMeta": "1", "boolMeta": "True", "strMeta": "physics"} + assert trace.dataset_metadata == {"enabled": "True", "count": "42"} + + # Then: the child span also has converted metadata + llm_span = trace.spans[0] + assert llm_span.user_metadata == {"intMeta": "1", "boolMeta": "True", "strMeta": "physics"} + class TestMultipleLoggerInstanceIsolation: """Test that multiple GalileoLogger instances have fully isolated state.