Skip to content

Commit cb26c51

Browse files
committed
feat(langchain): report cached and reasoning token usage
The Langchain and Langgraph integrations only recorded input, output, and total token counts, dropping cached input tokens and reasoning output tokens even though SPANDATA defines both and the OpenAI, OpenAI Agents, and Google GenAI integrations already report them. Extract cache_read/cached_tokens and reasoning/reasoning_tokens from both the LangChain usage_metadata detail shape and OpenAI-style details dicts, and set gen_ai.usage.input_tokens.cached and gen_ai.usage.output_tokens.reasoning on spans in both integrations. Fixes GH-6799
1 parent b9899f8 commit cb26c51

4 files changed

Lines changed: 164 additions & 10 deletions

File tree

sentry_sdk/integrations/langchain.py

Lines changed: 58 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -730,9 +730,9 @@ def on_tool_error(
730730

731731
def _extract_tokens(
732732
token_usage: "Any",
733-
) -> "tuple[Optional[int], Optional[int], Optional[int]]":
733+
) -> "tuple[Optional[int], Optional[int], Optional[int], Optional[int], Optional[int]]":
734734
if not token_usage:
735-
return None, None, None
735+
return None, None, None, None, None
736736

737737
input_tokens = _get_value(token_usage, "prompt_tokens") or _get_value(
738738
token_usage, "input_tokens"
@@ -742,32 +742,64 @@ def _extract_tokens(
742742
)
743743
total_tokens = _get_value(token_usage, "total_tokens")
744744

745-
return input_tokens, output_tokens, total_tokens
745+
# LangChain's usage_metadata nests these under input/output_token_details;
746+
# OpenAI-style dicts use prompt/completion_tokens_details.
747+
input_details = _get_value(token_usage, "input_token_details") or _get_value(
748+
token_usage, "prompt_tokens_details"
749+
)
750+
cached_tokens = None
751+
if input_details is not None:
752+
cached_tokens = _get_value(input_details, "cache_read") or _get_value(
753+
input_details, "cached_tokens"
754+
)
755+
756+
output_details = _get_value(token_usage, "output_token_details") or _get_value(
757+
token_usage, "completion_tokens_details"
758+
)
759+
reasoning_tokens = None
760+
if output_details is not None:
761+
reasoning_tokens = _get_value(output_details, "reasoning") or _get_value(
762+
output_details, "reasoning_tokens"
763+
)
764+
765+
return input_tokens, output_tokens, total_tokens, cached_tokens, reasoning_tokens
746766

747767

748768
def _extract_tokens_from_generations(
749769
generations: "Any",
750-
) -> "tuple[Optional[int], Optional[int], Optional[int]]":
770+
) -> "tuple[Optional[int], Optional[int], Optional[int], Optional[int], Optional[int]]":
751771
"""Extract token usage from response.generations structure."""
752772
if not generations:
753-
return None, None, None
773+
return None, None, None, None, None
754774

755775
total_input = 0
756776
total_output = 0
757777
total_total = 0
778+
total_cached = 0
779+
total_reasoning = 0
758780

759781
for gen_list in generations:
760782
for gen in gen_list:
761783
token_usage = _get_token_usage(gen)
762-
input_tokens, output_tokens, total_tokens = _extract_tokens(token_usage)
784+
(
785+
input_tokens,
786+
output_tokens,
787+
total_tokens,
788+
cached_tokens,
789+
reasoning_tokens,
790+
) = _extract_tokens(token_usage)
763791
total_input += input_tokens if input_tokens is not None else 0
764792
total_output += output_tokens if output_tokens is not None else 0
765793
total_total += total_tokens if total_tokens is not None else 0
794+
total_cached += cached_tokens if cached_tokens is not None else 0
795+
total_reasoning += reasoning_tokens if reasoning_tokens is not None else 0
766796

767797
return (
768798
total_input if total_input > 0 else None,
769799
total_output if total_output > 0 else None,
770800
total_total if total_total > 0 else None,
801+
total_cached if total_cached > 0 else None,
802+
total_reasoning if total_reasoning > 0 else None,
771803
)
772804

773805

@@ -802,11 +834,21 @@ def _get_token_usage(obj: "Any") -> "Optional[Dict[str, Any]]":
802834
def _record_token_usage(span: "Union[Span, StreamedSpan]", response: "Any") -> None:
803835
token_usage = _get_token_usage(response)
804836
if token_usage:
805-
input_tokens, output_tokens, total_tokens = _extract_tokens(token_usage)
837+
(
838+
input_tokens,
839+
output_tokens,
840+
total_tokens,
841+
cached_tokens,
842+
reasoning_tokens,
843+
) = _extract_tokens(token_usage)
806844
else:
807-
input_tokens, output_tokens, total_tokens = _extract_tokens_from_generations(
808-
response.generations
809-
)
845+
(
846+
input_tokens,
847+
output_tokens,
848+
total_tokens,
849+
cached_tokens,
850+
reasoning_tokens,
851+
) = _extract_tokens_from_generations(response.generations)
810852

811853
set_on_span = (
812854
span.set_attribute if isinstance(span, StreamedSpan) else span.set_data
@@ -821,6 +863,12 @@ def _record_token_usage(span: "Union[Span, StreamedSpan]", response: "Any") -> N
821863
if total_tokens is not None:
822864
set_on_span(SPANDATA.GEN_AI_USAGE_TOTAL_TOKENS, total_tokens)
823865

866+
if cached_tokens is not None:
867+
set_on_span(SPANDATA.GEN_AI_USAGE_INPUT_TOKENS_CACHED, cached_tokens)
868+
869+
if reasoning_tokens is not None:
870+
set_on_span(SPANDATA.GEN_AI_USAGE_OUTPUT_TOKENS_REASONING, reasoning_tokens)
871+
824872

825873
def _get_request_data(
826874
obj: "Any", args: "Any", kwargs: "Any"

sentry_sdk/integrations/langgraph.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -432,6 +432,8 @@ def _set_usage_data(span: "sentry_sdk.tracing.Span", messages: "Any") -> None:
432432
input_tokens = 0
433433
output_tokens = 0
434434
total_tokens = 0
435+
cached_tokens = 0
436+
reasoning_tokens = 0
435437

436438
for message in messages:
437439
response_metadata = message.get("response_metadata")
@@ -446,6 +448,12 @@ def _set_usage_data(span: "sentry_sdk.tracing.Span", messages: "Any") -> None:
446448
output_tokens += int(token_usage.get("completion_tokens", 0))
447449
total_tokens += int(token_usage.get("total_tokens", 0))
448450

451+
input_details = token_usage.get("prompt_tokens_details") or {}
452+
cached_tokens += int(input_details.get("cached_tokens") or 0)
453+
454+
output_details = token_usage.get("completion_tokens_details") or {}
455+
reasoning_tokens += int(output_details.get("reasoning_tokens") or 0)
456+
449457
set_on_span = (
450458
span.set_attribute if isinstance(span, StreamedSpan) else span.set_data
451459
)
@@ -462,6 +470,12 @@ def _set_usage_data(span: "sentry_sdk.tracing.Span", messages: "Any") -> None:
462470
total_tokens,
463471
)
464472

473+
if cached_tokens > 0:
474+
set_on_span(SPANDATA.GEN_AI_USAGE_INPUT_TOKENS_CACHED, cached_tokens)
475+
476+
if reasoning_tokens > 0:
477+
set_on_span(SPANDATA.GEN_AI_USAGE_OUTPUT_TOKENS_REASONING, reasoning_tokens)
478+
465479

466480
def _set_response_model_name(span: "sentry_sdk.tracing.Span", messages: "Any") -> None:
467481
if len(messages) == 0:

tests/integrations/langchain/test_langchain.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4977,3 +4977,58 @@ def test_transform_list_with_legacy_image_url(self):
49774977
"mime_type": "image/jpeg",
49784978
"content": "/9j/4AAQ...",
49794979
}
4980+
4981+
4982+
def test_extract_tokens_includes_cached_and_reasoning_details():
4983+
from sentry_sdk.integrations.langchain import _extract_tokens
4984+
4985+
# LangChain usage_metadata shape
4986+
usage = {
4987+
"input_tokens": 100,
4988+
"output_tokens": 50,
4989+
"total_tokens": 150,
4990+
"input_token_details": {"cache_read": 40},
4991+
"output_token_details": {"reasoning": 10},
4992+
}
4993+
assert _extract_tokens(usage) == (100, 50, 150, 40, 10)
4994+
4995+
# OpenAI-style details shape
4996+
usage = {
4997+
"prompt_tokens": 100,
4998+
"completion_tokens": 50,
4999+
"total_tokens": 150,
5000+
"prompt_tokens_details": {"cached_tokens": 30},
5001+
"completion_tokens_details": {"reasoning_tokens": 5},
5002+
}
5003+
assert _extract_tokens(usage) == (100, 50, 150, 30, 5)
5004+
5005+
# No details present
5006+
usage = {"input_tokens": 1, "output_tokens": 2, "total_tokens": 3}
5007+
assert _extract_tokens(usage) == (1, 2, 3, None, None)
5008+
5009+
5010+
def test_record_token_usage_sets_cached_and_reasoning_span_data():
5011+
from unittest.mock import MagicMock
5012+
5013+
from sentry_sdk.consts import SPANDATA
5014+
from sentry_sdk.integrations.langchain import _record_token_usage
5015+
5016+
span = MagicMock(spec=["set_data"])
5017+
response = MagicMock()
5018+
response.llm_output = None
5019+
response.generations = []
5020+
response.usage = None
5021+
response.token_usage = None
5022+
response.message = None
5023+
response.usage_metadata = {
5024+
"input_tokens": 100,
5025+
"output_tokens": 50,
5026+
"total_tokens": 150,
5027+
"input_token_details": {"cache_read": 40},
5028+
"output_token_details": {"reasoning": 10},
5029+
}
5030+
5031+
_record_token_usage(span, response)
5032+
5033+
span.set_data.assert_any_call(SPANDATA.GEN_AI_USAGE_INPUT_TOKENS_CACHED, 40)
5034+
span.set_data.assert_any_call(SPANDATA.GEN_AI_USAGE_OUTPUT_TOKENS_REASONING, 10)

tests/integrations/langgraph/test_langgraph.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2132,3 +2132,40 @@ def test_graph_bubble_up_ignored(sentry_init, capture_items):
21322132
model.invoke([HumanMessage(content="hi")])
21332133

21342134
assert len(events) == 0
2135+
2136+
2137+
def test_set_usage_data_includes_cached_and_reasoning_tokens():
2138+
from unittest.mock import MagicMock
2139+
2140+
from sentry_sdk.consts import SPANDATA
2141+
from sentry_sdk.integrations.langgraph import _set_usage_data
2142+
2143+
span = MagicMock(spec=["set_data"])
2144+
messages = [
2145+
{
2146+
"response_metadata": {
2147+
"token_usage": {
2148+
"prompt_tokens": 100,
2149+
"completion_tokens": 50,
2150+
"total_tokens": 150,
2151+
"prompt_tokens_details": {"cached_tokens": 40},
2152+
"completion_tokens_details": {"reasoning_tokens": 10},
2153+
}
2154+
}
2155+
},
2156+
{
2157+
"response_metadata": {
2158+
"token_usage": {
2159+
"prompt_tokens": 10,
2160+
"completion_tokens": 5,
2161+
"total_tokens": 15,
2162+
}
2163+
}
2164+
},
2165+
]
2166+
2167+
_set_usage_data(span, messages)
2168+
2169+
span.set_data.assert_any_call(SPANDATA.GEN_AI_USAGE_INPUT_TOKENS, 110)
2170+
span.set_data.assert_any_call(SPANDATA.GEN_AI_USAGE_INPUT_TOKENS_CACHED, 40)
2171+
span.set_data.assert_any_call(SPANDATA.GEN_AI_USAGE_OUTPUT_TOKENS_REASONING, 10)

0 commit comments

Comments
 (0)