Skip to content

Commit 1f198d7

Browse files
authored
Add reasoning tokens attribute to span / log when new sem conv is set.. (#4493)
1 parent 1cd2554 commit 1f198d7

4 files changed

Lines changed: 58 additions & 4 deletions

File tree

instrumentation-genai/opentelemetry-instrumentation-google-genai/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## Unreleased
99

10+
-Add `gen_ai.usage.reasoning.output_tokens` attribute to capture thinking tokens on spans/events when the experimental sem conv flag is set. Add thinking tokens to output tokens. ([#4313](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/4313))
1011
-Add `gen_ai.usage.cache_read.input_tokens` attribute to capture cached tokens on spans/events when the experimental sem conv flag is set. ([#4313](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/4313))
1112

1213
## Version 0.7b0 (2026-02-20)

instrumentation-genai/opentelemetry-instrumentation-google-genai/src/opentelemetry/instrumentation/google_genai/generate_content.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -525,6 +525,7 @@ def __init__(
525525
self._error_type = None
526526
self._input_tokens = 0
527527
self._cached_tokens = 0
528+
self._thinking_tokens = 0
528529
self._output_tokens = 0
529530
sem_conv_opt_in_mode = _OpenTelemetrySemanticConventionStability._get_opentelemetry_stability_opt_in_mode(
530531
_OpenTelemetryStabilitySignalType.GEN_AI
@@ -633,12 +634,21 @@ def _maybe_update_token_counts(self, response: GenerateContentResponse):
633634
cached_tokens = _get_response_property(
634635
response, "usage_metadata.cached_content_token_count"
635636
)
637+
thinking_tokens = _get_response_property(
638+
response, "usage_metadata.thoughts_token_count"
639+
)
636640
if cached_tokens and isinstance(cached_tokens, int):
637641
self._cached_tokens = cached_tokens
638642
if input_tokens and isinstance(input_tokens, int):
639643
self._input_tokens = input_tokens
640644
if output_tokens and isinstance(output_tokens, int):
641645
self._output_tokens = output_tokens
646+
if thinking_tokens and isinstance(thinking_tokens, int):
647+
# Pricing of tokens is the sum of output tokens and thinking tokens:
648+
# https://ai.google.dev/gemini-api/docs/thinking#pricing
649+
# Also the sem conv recommends combining these counts.
650+
self._output_tokens += thinking_tokens
651+
self._thinking_tokens = thinking_tokens
642652

643653
def _maybe_update_error_type(self, response: GenerateContentResponse):
644654
if response.candidates:
@@ -778,6 +788,14 @@ def _maybe_log_completion_details(
778788
event.attributes[
779789
gen_ai_attributes.GEN_AI_USAGE_CACHE_READ_INPUT_TOKENS
780790
] = self._cached_tokens
791+
# TODO: replace these strings with the sem conv constant in `gen_ai_attributes` once it becomes available.
792+
span.set_attribute(
793+
"gen_ai.usage.reasoning.output_tokens",
794+
self._thinking_tokens,
795+
)
796+
event.attributes["gen_ai.usage.reasoning.output_tokens"] = (
797+
self._thinking_tokens
798+
)
781799
tool_definitions = tool_definitions or []
782800
self.completion_hook.on_completion(
783801
inputs=input_messages,

instrumentation-genai/opentelemetry-instrumentation-google-genai/tests/generate_content/nonstreaming_base.py

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -261,17 +261,25 @@ def test_generated_span_has_vertex_ai_system_when_configured(self):
261261

262262
def test_generated_span_counts_tokens(self):
263263
self.configure_valid_response(
264-
input_tokens=123, output_tokens=456, cached_tokens=50
264+
input_tokens=123,
265+
output_tokens=456,
266+
cached_tokens=50,
267+
thinking_tokens=17,
265268
)
266269
self.generate_content(model="gemini-2.0-flash", contents="Some input")
267270
self.otel.assert_has_span_named("generate_content gemini-2.0-flash")
268271
span = self.otel.get_span_named("generate_content gemini-2.0-flash")
269272
self.assertEqual(span.attributes["gen_ai.usage.input_tokens"], 123)
270-
self.assertEqual(span.attributes["gen_ai.usage.output_tokens"], 456)
273+
self.assertEqual(
274+
span.attributes["gen_ai.usage.output_tokens"], 456 + 17
275+
)
271276
# New sem conv should not appear when flag is not experimental mode..
272277
self.assertNotIn(
273278
"gen_ai.usage.cache_read.input_tokens", span.attributes
274279
)
280+
self.assertNotIn(
281+
"gen_ai.usage.reasoning.output_tokens", span.attributes
282+
)
275283

276284
@patch.dict(
277285
"os.environ",
@@ -452,7 +460,9 @@ def test_new_semconv_record_completion_as_log(self):
452460
self.setUp()
453461
with patched_environ, patched_otel_mapping:
454462
self.configure_valid_response(
455-
text=output, cached_tokens=50
463+
text=output,
464+
cached_tokens=50,
465+
thinking_tokens=17,
456466
)
457467
self.generate_content(
458468
model="gemini-2.0-flash",
@@ -475,6 +485,16 @@ def test_new_semconv_record_completion_as_log(self):
475485
],
476486
50,
477487
)
488+
self.assertEqual(
489+
event.attributes[
490+
"gen_ai.usage.reasoning.output_tokens"
491+
],
492+
17,
493+
)
494+
self.assertEqual(
495+
event.attributes["gen_ai.usage.output_tokens"],
496+
17,
497+
)
478498
assert (
479499
event.attributes[
480500
"gcp.gen_ai.operation.config.response_schema"
@@ -780,7 +800,9 @@ def test_new_semconv_record_completion_in_span(self):
780800
self.setUp()
781801
with patched_environ, patched_otel_mapping:
782802
self.configure_valid_response(
783-
text="Some response content", cached_tokens=50
803+
text="Some response content",
804+
cached_tokens=50,
805+
thinking_tokens=19,
784806
)
785807
self.generate_content(
786808
model="gemini-2.0-flash",
@@ -800,6 +822,16 @@ def test_new_semconv_record_completion_in_span(self):
800822
],
801823
50,
802824
)
825+
self.assertEqual(
826+
span.attributes[
827+
"gen_ai.usage.reasoning.output_tokens"
828+
],
829+
19,
830+
)
831+
self.assertEqual(
832+
span.attributes["gen_ai.usage.output_tokens"],
833+
19,
834+
)
803835
if mode in [
804836
ContentCapturingMode.SPAN_ONLY,
805837
ContentCapturingMode.SPAN_AND_EVENT,

instrumentation-genai/opentelemetry-instrumentation-google-genai/tests/generate_content/util.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ def create_response(
2525
candidates: Optional[list[genai_types.Candidate]] = None,
2626
text: Optional[str] = None,
2727
input_tokens: Optional[int] = None,
28+
thinking_tokens: Optional[int] = None,
2829
output_tokens: Optional[int] = None,
2930
cached_tokens: Optional[int] = None,
3031
model_version: Optional[str] = None,
@@ -56,6 +57,8 @@ def create_response(
5657
usage_metadata.candidates_token_count = output_tokens
5758
if cached_tokens is not None:
5859
usage_metadata.cached_content_token_count = cached_tokens
60+
if thinking_tokens is not None:
61+
usage_metadata.thoughts_token_count = thinking_tokens
5962
return genai_types.GenerateContentResponse(
6063
candidates=candidates,
6164
usage_metadata=usage_metadata,

0 commit comments

Comments
 (0)