Attach real input/output/token usage to provider spans for Langfuse cost tracking
Langfuse showed $0.00 cost on every trace even after tracing was wired up: provider.bedrock_converse.* spans carried only latency, no content or usage. Converse's own response already includes token usage -- it just wasn't being read. Captured on the adapter (last_usage, no interface change) and attached to the span using Langfuse's own OTel attribute convention (langfuse.observation.*), which its docs say takes precedence over generic GenAI attributes. Verified live: a fresh trace now shows real prompt/completion tokens and Langfuse computes real cost once given the model's actual per-token price.
This commit is contained in:
@@ -78,6 +78,10 @@ class BedrockConverseAnswerGenerator:
|
|||||||
self._client = client
|
self._client = client
|
||||||
self._model_id = model_id
|
self._model_id = model_id
|
||||||
self._max_tokens = max_tokens
|
self._max_tokens = max_tokens
|
||||||
|
# Converse's own response usage, captured for the observability layer
|
||||||
|
# (rag/instrumentation.py) to attach to its span. Not part of the
|
||||||
|
# `generate()` return contract -- every existing caller is unaffected.
|
||||||
|
self.last_usage: dict[str, int] | None = None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def model_id(self) -> str:
|
def model_id(self) -> str:
|
||||||
@@ -126,6 +130,8 @@ class BedrockConverseAnswerGenerator:
|
|||||||
f"{self._model_id} could not be invoked: {type(error).__name__}"
|
f"{self._model_id} could not be invoked: {type(error).__name__}"
|
||||||
) from error
|
) from error
|
||||||
|
|
||||||
|
self.last_usage = dict(response.get("usage") or {}) or None
|
||||||
|
|
||||||
if response.get("stopReason") in _EMPTY_STOP_REASONS:
|
if response.get("stopReason") in _EMPTY_STOP_REASONS:
|
||||||
raise AnswerGenerationUnavailable(
|
raise AnswerGenerationUnavailable(
|
||||||
f"{self._model_id} produced no usable content "
|
f"{self._model_id} produced no usable content "
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ those files or duplicating their domain decisions.
|
|||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from .agent import RagAgent
|
from .agent import RagAgent
|
||||||
@@ -42,6 +43,40 @@ def _failure_reason(exc: BaseException) -> str:
|
|||||||
return "error"
|
return "error"
|
||||||
|
|
||||||
|
|
||||||
|
_USAGE_KEY_MAP = {
|
||||||
|
"inputTokens": "input",
|
||||||
|
"outputTokens": "output",
|
||||||
|
"totalTokens": "total",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _annotate_generation(
|
||||||
|
span: Any, model_id: str, system: str, user: str, output: str, usage: dict | None
|
||||||
|
) -> None:
|
||||||
|
"""Attach real request/response content to a provider span.
|
||||||
|
|
||||||
|
Attribute names are Langfuse's own OTel convention (`langfuse.observation.*`),
|
||||||
|
which its docs say take precedence over generic OpenTelemetry GenAI
|
||||||
|
attributes -- so this is read reliably regardless of what else instruments
|
||||||
|
the same span. Only real values from the actual call are recorded; usage is
|
||||||
|
omitted entirely (not estimated) when the provider response carried none.
|
||||||
|
"""
|
||||||
|
span.set_attribute("langfuse.observation.model.name", model_id)
|
||||||
|
span.set_attribute(
|
||||||
|
"langfuse.observation.input",
|
||||||
|
json.dumps({"system": system, "user": user}, ensure_ascii=False),
|
||||||
|
)
|
||||||
|
span.set_attribute("langfuse.observation.output", output)
|
||||||
|
if usage:
|
||||||
|
span.set_attribute(
|
||||||
|
"langfuse.observation.usage_details",
|
||||||
|
json.dumps(
|
||||||
|
{_USAGE_KEY_MAP.get(k, k): v for k, v in usage.items()},
|
||||||
|
ensure_ascii=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class InstrumentedGenerator:
|
class InstrumentedGenerator:
|
||||||
def __init__(self, delegate: Any, metrics: Metrics) -> None:
|
def __init__(self, delegate: Any, metrics: Metrics) -> None:
|
||||||
self._delegate = delegate
|
self._delegate = delegate
|
||||||
@@ -58,9 +93,9 @@ class InstrumentedGenerator:
|
|||||||
"generation": "generate",
|
"generation": "generate",
|
||||||
"entailment": "entailment",
|
"entailment": "entailment",
|
||||||
}.get(current_stage(), "generate")
|
}.get(current_stage(), "generate")
|
||||||
with dependency_span(self._provider, operation):
|
with dependency_span(self._provider, operation) as span:
|
||||||
try:
|
try:
|
||||||
return self._delegate.generate(system, user, schema)
|
result = self._delegate.generate(system, user, schema)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
self._metrics.increment(
|
self._metrics.increment(
|
||||||
PROVIDER_FAILURE,
|
PROVIDER_FAILURE,
|
||||||
@@ -69,6 +104,16 @@ class InstrumentedGenerator:
|
|||||||
reason=_failure_reason(exc),
|
reason=_failure_reason(exc),
|
||||||
)
|
)
|
||||||
raise
|
raise
|
||||||
|
if span is not None:
|
||||||
|
_annotate_generation(
|
||||||
|
span,
|
||||||
|
self.model_id,
|
||||||
|
system,
|
||||||
|
user,
|
||||||
|
result,
|
||||||
|
getattr(self._delegate, "last_usage", None),
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
class InstrumentedEmbedder:
|
class InstrumentedEmbedder:
|
||||||
|
|||||||
Reference in New Issue
Block a user