Skip to content

Commit 4cd3c10

Browse files
fix(handlers): pass ingestion_hook through handler → logger chain (#479)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 25c51cd commit 4cd3c10

File tree

4 files changed

+78
-4
lines changed

4 files changed

+78
-4
lines changed

src/galileo/decorator.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -978,6 +978,7 @@ def get_logger_instance(
978978
log_stream: Optional[str] = None,
979979
experiment_id: Optional[str] = None,
980980
mode: Optional[str] = None,
981+
ingestion_hook: Optional[Callable] = None,
981982
) -> GalileoLogger:
982983
"""
983984
Get the Galileo Logger instance for the current decorator context.
@@ -1009,6 +1010,8 @@ def get_logger_instance(
10091010
kwargs["trace_id"] = trace_id_from_context
10101011
if span_id_from_context:
10111012
kwargs["span_id"] = span_id_from_context
1013+
if ingestion_hook is not None:
1014+
kwargs["ingestion_hook"] = ingestion_hook
10121015

10131016
return GalileoLoggerSingleton().get(**kwargs)
10141017

src/galileo/handlers/base_handler.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,8 +43,12 @@ def __init__(
4343
flush_on_chain_end: bool = True,
4444
ingestion_hook: Optional[Callable[[TracesIngestRequest], None]] = None,
4545
):
46-
self._galileo_logger: GalileoLogger = galileo_logger or galileo_context.get_logger_instance()
47-
if ingestion_hook:
46+
self._galileo_logger: GalileoLogger = galileo_logger or galileo_context.get_logger_instance(
47+
ingestion_hook=ingestion_hook
48+
)
49+
if galileo_logger and ingestion_hook:
50+
if self._galileo_logger.mode == "distributed":
51+
raise ValueError("ingestion_hook can only be used in batch mode")
4852
self._galileo_logger._ingestion_hook = ingestion_hook
4953
self._start_new_trace: bool = start_new_trace
5054
self._flush_on_chain_end: bool = flush_on_chain_end

src/galileo/utils/singleton.py

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import logging
22
import threading
3-
from typing import ClassVar, Optional
3+
from typing import Callable, ClassVar, Optional
44

55
from galileo.logger import GalileoLogger
66
from galileo.schema.metrics import LocalMetricConfig
@@ -50,6 +50,7 @@ def _get_key(
5050
experiment_id: Optional[str] = None,
5151
trace_id: Optional[str] = None,
5252
span_id: Optional[str] = None,
53+
ingestion_hook_id: Optional[int] = None,
5354
) -> tuple[str, ...]:
5455
"""
5556
Generate a key tuple based on project, log_stream, and tracing parameters.
@@ -99,6 +100,8 @@ def _get_key(
99100
base_key = (*base_key, trace_id)
100101
if span_id is not None:
101102
base_key = (*base_key, span_id)
103+
if ingestion_hook_id is not None:
104+
base_key = (*base_key, str(ingestion_hook_id))
102105

103106
return base_key
104107

@@ -112,6 +115,7 @@ def get(
112115
local_metrics: Optional[list[LocalMetricConfig]] = None,
113116
trace_id: Optional[str] = None,
114117
span_id: Optional[str] = None,
118+
ingestion_hook: Optional[Callable] = None,
115119
) -> GalileoLogger:
116120
"""
117121
Retrieve an existing GalileoLogger or create a new one if it does not exist.
@@ -141,7 +145,15 @@ def get(
141145
mode = _get_mode_or_default(mode)
142146

143147
# Compute the key based on provided parameters or environment variables.
144-
key = GalileoLoggerSingleton._get_key(project, log_stream, mode, experiment_id, trace_id, span_id)
148+
key = GalileoLoggerSingleton._get_key(
149+
project,
150+
log_stream,
151+
mode,
152+
experiment_id,
153+
trace_id,
154+
span_id,
155+
ingestion_hook_id=id(ingestion_hook) if ingestion_hook else None,
156+
)
145157

146158
# First check without acquiring lock for performance.
147159
if key in self._galileo_loggers:
@@ -162,6 +174,7 @@ def get(
162174
"mode": mode,
163175
"trace_id": trace_id,
164176
"span_id": span_id,
177+
"ingestion_hook": ingestion_hook,
165178
}
166179
# Create the logger with filtered kwargs.
167180
logger = GalileoLogger(**{k: v for k, v in galileo_client_init_args.items() if v is not None})

tests/test_langchain.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1158,3 +1158,57 @@ def test_updates_with_single_langgraph_key(self) -> None:
11581158
update_root_to_agent(parent_run_id, metadata, parent_node)
11591159

11601160
assert parent_node.node_type == "agent"
1161+
1162+
1163+
class TestGalileoCallbackIngestionHookWithoutCredentials:
1164+
"""SC-54690: GalileoCallback/GalileoAsyncCallback with ingestion_hook should work without API credentials.
1165+
1166+
When a user provides an ingestion_hook, the handler should not require Galileo API configuration
1167+
(GALILEO_API_KEY, etc.) because the hook bypasses the API entirely. This test verifies the fix
1168+
without mocking Projects/LogStreams/Traces, so the real code path is exercised.
1169+
"""
1170+
1171+
@pytest.fixture(autouse=True)
1172+
def clear_galileo_config(self, monkeypatch):
1173+
"""Remove Galileo credentials and reset config to simulate no-API-key scenario."""
1174+
from galileo.config import GalileoPythonConfig
1175+
from galileo.utils.singleton import GalileoLoggerSingleton
1176+
1177+
# Given: no Galileo API credentials are configured
1178+
monkeypatch.delenv("GALILEO_API_KEY", raising=False)
1179+
monkeypatch.delenv("GALILEO_PROJECT", raising=False)
1180+
monkeypatch.delenv("GALILEO_LOG_STREAM", raising=False)
1181+
monkeypatch.setenv("GALILEO_CONSOLE_URL", "https://console.galileo.ai/")
1182+
1183+
if GalileoPythonConfig._instance is not None:
1184+
GalileoPythonConfig._instance.reset()
1185+
1186+
GalileoLoggerSingleton().reset_all()
1187+
1188+
yield
1189+
1190+
GalileoLoggerSingleton().reset_all()
1191+
1192+
def test_callback_with_ingestion_hook_no_credentials(self):
1193+
"""GalileoCallback(ingestion_hook=...) should not require API credentials."""
1194+
# Given: a sync ingestion hook
1195+
mock_hook = Mock()
1196+
1197+
# When: creating GalileoCallback with only an ingestion hook (no pre-created logger)
1198+
callback = GalileoCallback(ingestion_hook=mock_hook)
1199+
1200+
# Then: the callback is created successfully and the hook is attached
1201+
assert callback._handler._galileo_logger._ingestion_hook is mock_hook
1202+
1203+
def test_async_callback_with_ingestion_hook_no_credentials(self):
1204+
"""GalileoAsyncCallback(ingestion_hook=...) should not require API credentials."""
1205+
from galileo.handlers.langchain import GalileoAsyncCallback
1206+
1207+
# Given: an async ingestion hook
1208+
mock_hook = Mock()
1209+
1210+
# When: creating GalileoAsyncCallback with only an ingestion hook (no pre-created logger)
1211+
callback = GalileoAsyncCallback(ingestion_hook=mock_hook)
1212+
1213+
# Then: the callback is created successfully and the hook is attached
1214+
assert callback._handler._galileo_logger._ingestion_hook is mock_hook

0 commit comments

Comments
 (0)