Skip to content

Commit 5580cbf

Browse files
authored
feat(langfuse): add embedder usage metrics for langfuse (#2542)
* add: langfuse log levels * fix: dry finally block * add: embedder cost extraction * fix: static typing
1 parent aa9cc3e commit 5580cbf

2 files changed

Lines changed: 109 additions & 0 deletions

File tree

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

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -395,6 +395,30 @@ def handle(self, span: LangfuseSpan, component_type: Optional[str]) -> None:
395395
usage = meta[0].get("usage")
396396
sanitized_usage = _sanitize_usage_data(usage) if usage else None
397397
span.raw_span().update(usage_details=sanitized_usage, model=meta[0].get("model"))
398+
elif component_type and component_type.endswith("Embedder"):
399+
# Extract usage data from embedder output
400+
output = span.get_data().get(_COMPONENT_OUTPUT_KEY, {})
401+
meta = output.get("meta")
402+
403+
if meta and isinstance(meta, dict):
404+
# Build update parameters with available data
405+
update_params: dict[str, Any] = {}
406+
407+
# Try both common formats: 'usage' (OpenAI) or 'billed_units' (Cohere)
408+
usage = meta.get("usage") or meta.get("billed_units")
409+
if usage:
410+
sanitized_usage = _sanitize_usage_data(usage)
411+
if sanitized_usage:
412+
update_params["usage_details"] = sanitized_usage
413+
414+
# Some embedders may provide model information
415+
model = meta.get("model")
416+
if model and isinstance(model, str):
417+
update_params["model"] = model
418+
419+
# Single update call if we have data to update
420+
if update_params:
421+
span.raw_span().update(**update_params)
398422

399423

400424
class LangfuseTracer(Tracer):

integrations/langfuse/tests/test_tracer.py

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -411,6 +411,91 @@ def test_create_span_non_component(self):
411411
# Verify start_as_current_span was called for the actual span creation (not just parent)
412412
assert mock_client.start_as_current_span.call_count == 2 # Once for parent, once for the span
413413

414+
def test_handle_embedder_with_openai_format(self):
415+
"""Test that embedder usage is extracted in OpenAI format."""
416+
mock_span = Mock()
417+
mock_span.raw_span.return_value = mock_span
418+
mock_span.get_data.return_value = {
419+
"haystack.component.type": "OpenAITextEmbedder",
420+
"haystack.component.output": {
421+
"embedding": [0.1, 0.2, 0.3],
422+
"meta": {"model": "custom-model", "usage": {"prompt_tokens": 15, "total_tokens": 15}},
423+
},
424+
}
425+
426+
handler = DefaultSpanHandler()
427+
handler.handle(mock_span, component_type="OpenAITextEmbedder")
428+
429+
assert mock_span.update.call_count == 1
430+
assert mock_span.update.call_args_list[0][1] == {
431+
"usage_details": {"prompt_tokens": 15, "total_tokens": 15},
432+
"model": "custom-model",
433+
}
434+
435+
def test_handle_embedder_with_cohere_format(self):
436+
"""Test that embedder usage is extracted in Cohere billed_units format."""
437+
mock_span = Mock()
438+
mock_span.raw_span.return_value = mock_span
439+
mock_span.get_data.return_value = {
440+
"haystack.component.type": "CohereTextEmbedder",
441+
"haystack.component.output": {
442+
"embedding": [0.1, 0.2, 0.3],
443+
"meta": {"api_version": {"version": "1"}, "billed_units": {"input_tokens": 4}},
444+
},
445+
}
446+
447+
handler = DefaultSpanHandler()
448+
handler.handle(mock_span, component_type="CohereTextEmbedder")
449+
450+
assert mock_span.update.call_count == 1
451+
assert mock_span.update.call_args_list[0][1] == {"usage_details": {"input_tokens": 4}}
452+
453+
def test_handle_embedder_without_usage(self):
454+
"""Test that embedders without usage data are handled gracefully."""
455+
mock_span = Mock()
456+
mock_span.raw_span.return_value = mock_span
457+
mock_span.get_data.return_value = {
458+
"haystack.component.type": "SentenceTransformersTextEmbedder",
459+
"haystack.component.output": {
460+
"embedding": [0.1, 0.2, 0.3],
461+
"meta": {}, # No usage data
462+
},
463+
}
464+
465+
handler = DefaultSpanHandler()
466+
handler.handle(mock_span, component_type="SentenceTransformersTextEmbedder")
467+
468+
# Should not call update when no usage data is available
469+
assert mock_span.update.call_count == 0
470+
471+
def test_handle_embedder_with_nested_usage(self):
472+
"""Test that embedders with nested usage data are sanitized correctly."""
473+
mock_span = Mock()
474+
mock_span.raw_span.return_value = mock_span
475+
mock_span.get_data.return_value = {
476+
"haystack.component.type": "CustomEmbedder",
477+
"haystack.component.output": {
478+
"embedding": [0.1, 0.2, 0.3],
479+
"meta": {
480+
"model": "custom-model",
481+
"usage": {
482+
"cache_creation": {"input_tokens": 10},
483+
"cache_read": {"input_tokens": 5},
484+
"total_tokens": 15,
485+
},
486+
},
487+
},
488+
}
489+
490+
handler = DefaultSpanHandler()
491+
handler.handle(mock_span, component_type="CustomEmbedder")
492+
493+
assert mock_span.update.call_count == 1
494+
assert mock_span.update.call_args_list[0][1] == {
495+
"usage_details": {"cache_creation.input_tokens": 10, "cache_read.input_tokens": 5, "total_tokens": 15},
496+
"model": "custom-model",
497+
}
498+
414499

415500
class TestCustomSpanHandler:
416501
def test_handle(self):

0 commit comments

Comments
 (0)