Skip to content

Commit a901b0e

Browse files
authored
feat(langfuse): embedder, retriever and generator as obs. type (#2497)
* add: embedder, retriever and generator as obs. type * add: mock call obs * add: improved code readability * add: else span case * add: static typing * fix: hatch run fmt
1 parent ed3f0d2 commit a901b0e

2 files changed

Lines changed: 146 additions & 35 deletions

File tree

‎integrations/langfuse/src/haystack_integrations/tracing/langfuse/tracer.py‎

Lines changed: 30 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
from contextvars import ContextVar
1111
from dataclasses import dataclass
1212
from datetime import datetime
13-
from typing import Any, Dict, Iterator, List, Literal, Optional
13+
from typing import Any, Dict, Iterator, List, Literal, Optional, cast
1414

1515
from haystack import default_from_dict, default_to_dict, logging
1616
from haystack.dataclasses import ChatMessage
@@ -25,27 +25,6 @@
2525
logger = logging.getLogger(__name__)
2626

2727
HAYSTACK_LANGFUSE_ENFORCE_FLUSH_ENV_VAR = "HAYSTACK_LANGFUSE_ENFORCE_FLUSH"
28-
_SUPPORTED_GENERATORS = [
29-
"AzureOpenAIGenerator",
30-
"OpenAIGenerator",
31-
"AnthropicGenerator",
32-
"HuggingFaceAPIGenerator",
33-
"HuggingFaceLocalGenerator",
34-
"CohereGenerator",
35-
"OllamaGenerator",
36-
]
37-
_SUPPORTED_CHAT_GENERATORS = [
38-
"AmazonBedrockChatGenerator",
39-
"AzureOpenAIChatGenerator",
40-
"OpenAIChatGenerator",
41-
"AnthropicChatGenerator",
42-
"HuggingFaceAPIChatGenerator",
43-
"HuggingFaceLocalChatGenerator",
44-
"CohereChatGenerator",
45-
"OllamaChatGenerator",
46-
"GoogleGenAIChatGenerator",
47-
]
48-
_ALL_SUPPORTED_GENERATORS = _SUPPORTED_GENERATORS + _SUPPORTED_CHAT_GENERATORS
4928

5029
# These are the keys used by Haystack for traces and span.
5130
# We keep them here to avoid making typos when using them.
@@ -58,6 +37,9 @@
5837
_COMPONENT_OUTPUT_KEY = "haystack.component.output"
5938
_COMPONENT_INPUT_KEY = "haystack.component.input"
6039

40+
# Type alias for observation span types
41+
ObservationSpanType = Literal["tool", "agent", "retriever", "embedding", "generation"]
42+
6143
# External session metadata for trace correlation (Haystack system)
6244
# Stores trace_id, user_id, session_id, tags, version for root trace creation
6345
tracing_context_var: ContextVar[Dict[Any, Any]] = ContextVar("tracing_context")
@@ -340,12 +322,26 @@ def create_span(self, context: SpanContext) -> LangfuseSpan:
340322
span._span.update_trace(**trace_attrs)
341323

342324
return span
343-
elif context.component_type == "ToolInvoker":
344-
return LangfuseSpan(self.tracer.start_as_current_observation(name=context.name, as_type="tool"))
325+
326+
span_type = None
327+
328+
if context.component_type == "ToolInvoker":
329+
span_type = "tool"
345330
elif context.operation_name == "haystack.agent.run":
346-
return LangfuseSpan(self.tracer.start_as_current_observation(name=context.name, as_type="agent"))
347-
elif context.component_type in _ALL_SUPPORTED_GENERATORS:
348-
return LangfuseSpan(self.tracer.start_as_current_observation(name=context.name, as_type="generation"))
331+
span_type = "agent"
332+
elif context.component_type and context.component_type.endswith("Retriever"):
333+
span_type = "retriever"
334+
elif context.component_type and context.component_type.endswith("Embedder"):
335+
span_type = "embedding"
336+
elif context.component_type and context.component_type.endswith("Generator"):
337+
span_type = "generation"
338+
339+
if span_type:
340+
return LangfuseSpan(
341+
self.tracer.start_as_current_observation(
342+
name=context.name, as_type=cast(ObservationSpanType, span_type)
343+
)
344+
)
349345
else:
350346
return LangfuseSpan(self.tracer.start_as_current_span(name=context.name))
351347

@@ -371,14 +367,7 @@ def handle(self, span: LangfuseSpan, component_type: Optional[str]) -> None:
371367
formatted_names = [f"{name} (x{count})" if count > 1 else name for name, count in tool_counts.items()]
372368
span.raw_span().update(name=f"{tool_invoker_name} - {sorted(formatted_names)}")
373369

374-
if component_type in _SUPPORTED_GENERATORS:
375-
meta = span.get_data().get(_COMPONENT_OUTPUT_KEY, {}).get("meta")
376-
if meta:
377-
usage = meta[0].get("usage")
378-
sanitized_usage = _sanitize_usage_data(usage) if usage else None
379-
span.raw_span().update(usage_details=sanitized_usage, model=meta[0].get("model"))
380-
381-
if component_type in _SUPPORTED_CHAT_GENERATORS:
370+
if component_type and component_type.endswith("ChatGenerator"):
382371
replies = span.get_data().get(_COMPONENT_OUTPUT_KEY, {}).get("replies")
383372
if replies:
384373
meta = replies[0].meta
@@ -396,6 +385,12 @@ def handle(self, span: LangfuseSpan, component_type: Optional[str]) -> None:
396385
model=meta.get("model"),
397386
completion_start_time=completion_start_time,
398387
)
388+
elif component_type and component_type.endswith("Generator"):
389+
meta = span.get_data().get(_COMPONENT_OUTPUT_KEY, {}).get("meta")
390+
if meta:
391+
usage = meta[0].get("usage")
392+
sanitized_usage = _sanitize_usage_data(usage) if usage else None
393+
span.raw_span().update(usage_details=sanitized_usage, model=meta[0].get("model"))
399394

400395

401396
class LangfuseTracer(Tracer):

‎integrations/langfuse/tests/test_tracer.py‎

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -295,6 +295,122 @@ def test_handle_bad_completion_start_time(self, caplog):
295295
"completion_start_time": None,
296296
}
297297

298+
def test_create_span_custom_chat_generator(self):
299+
"""Test that custom chat generators create 'generation' span type."""
300+
mock_client = Mock()
301+
mock_client.start_as_current_span = Mock(return_value=MockContextManager())
302+
mock_client.start_as_current_observation = Mock(return_value=MockContextManager())
303+
304+
handler = DefaultSpanHandler()
305+
handler.init_tracer(mock_client)
306+
307+
context = SpanContext(
308+
name="MistralChatGenerator",
309+
operation_name="haystack.component.run",
310+
component_type="MistralChatGenerator",
311+
tags={},
312+
parent_span=LangfuseSpan(mock_client.start_as_current_span()),
313+
)
314+
315+
span = handler.create_span(context)
316+
assert isinstance(span, LangfuseSpan)
317+
mock_client.start_as_current_observation.assert_called_once_with(
318+
name="MistralChatGenerator", as_type="generation"
319+
)
320+
321+
def test_create_span_custom_generator(self):
322+
"""Test that custom generators create 'generation' span type."""
323+
mock_client = Mock()
324+
mock_client.start_as_current_span = Mock(return_value=MockContextManager())
325+
mock_client.start_as_current_observation = Mock(return_value=MockContextManager())
326+
327+
handler = DefaultSpanHandler()
328+
handler.init_tracer(mock_client)
329+
330+
context = SpanContext(
331+
name="CustomAPIGenerator",
332+
operation_name="haystack.component.run",
333+
component_type="CustomAPIGenerator",
334+
tags={},
335+
parent_span=LangfuseSpan(mock_client.start_as_current_span()),
336+
)
337+
338+
span = handler.create_span(context)
339+
assert isinstance(span, LangfuseSpan)
340+
mock_client.start_as_current_observation.assert_called_once_with(
341+
name="CustomAPIGenerator", as_type="generation"
342+
)
343+
344+
def test_create_span_retriever(self):
345+
"""Test that retrievers create 'retriever' span type."""
346+
mock_client = Mock()
347+
mock_client.start_as_current_span = Mock(return_value=MockContextManager())
348+
mock_client.start_as_current_observation = Mock(return_value=MockContextManager())
349+
350+
handler = DefaultSpanHandler()
351+
handler.init_tracer(mock_client)
352+
353+
context = SpanContext(
354+
name="InMemoryBM25Retriever",
355+
operation_name="haystack.component.run",
356+
component_type="InMemoryBM25Retriever",
357+
tags={},
358+
parent_span=LangfuseSpan(mock_client.start_as_current_span()),
359+
)
360+
361+
span = handler.create_span(context)
362+
assert isinstance(span, LangfuseSpan)
363+
mock_client.start_as_current_observation.assert_called_once_with(
364+
name="InMemoryBM25Retriever", as_type="retriever"
365+
)
366+
367+
def test_create_span_embedder(self):
368+
"""Test that embedders create 'embedding' span type."""
369+
mock_client = Mock()
370+
mock_client.start_as_current_span = Mock(return_value=MockContextManager())
371+
mock_client.start_as_current_observation = Mock(return_value=MockContextManager())
372+
373+
handler = DefaultSpanHandler()
374+
handler.init_tracer(mock_client)
375+
376+
context = SpanContext(
377+
name="SentenceTransformersDocumentEmbedder",
378+
operation_name="haystack.component.run",
379+
component_type="SentenceTransformersDocumentEmbedder",
380+
tags={},
381+
parent_span=LangfuseSpan(mock_client.start_as_current_span()),
382+
)
383+
384+
span = handler.create_span(context)
385+
assert isinstance(span, LangfuseSpan)
386+
mock_client.start_as_current_observation.assert_called_once_with(
387+
name="SentenceTransformersDocumentEmbedder", as_type="embedding"
388+
)
389+
390+
def test_create_span_non_component(self):
391+
"""Test that non-matching components create regular spans."""
392+
mock_client = Mock()
393+
mock_client.start_as_current_span = Mock(return_value=MockContextManager())
394+
mock_client.start_as_current_observation = Mock(return_value=MockContextManager())
395+
396+
handler = DefaultSpanHandler()
397+
handler.init_tracer(mock_client)
398+
399+
context = SpanContext(
400+
name="DocumentJoiner",
401+
operation_name="haystack.component.run",
402+
component_type="DocumentJoiner",
403+
tags={},
404+
parent_span=LangfuseSpan(mock_client.start_as_current_span()),
405+
)
406+
407+
span = handler.create_span(context)
408+
assert isinstance(span, LangfuseSpan)
409+
# Non-matching components should use start_as_current_span, not start_as_current_observation
410+
mock_client.start_as_current_observation.assert_not_called()
411+
# Verify start_as_current_span was called for the actual span creation (not just parent)
412+
assert mock_client.start_as_current_span.call_count == 2 # Once for parent, once for the span
413+
298414

299415
class TestCustomSpanHandler:
300416
def test_handle(self):

0 commit comments

Comments
 (0)