Skip to content

Commit 5429c6c

Browse files
mwiatrowskiaabmass
andauthored
feat: propagate custom genai generate_content attributes to logs, in addition to spans (#4103)
* feat propagate custom genai generate_content attributes to logs in addition to spans * Put the extra attributes on logs that follow the old semanting convention as well * Use dict[str, AttributeValue] instead of dict[str, Any] * Revert "Put the extra attributes on logs that follow the old semanting convention as well" This reverts commit 72a5c99. * Update CHANGELOG.md * Fix lint --------- Co-authored-by: Aaron Abbott <aaronabbott@google.com>
1 parent ea03d20 commit 5429c6c

4 files changed

Lines changed: 115 additions & 24 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
@@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
88
## Unreleased
99

1010
- Enable the addition of custom attributes to the `generate_content {model.name}` span via the Context API. ([#3961](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3961)).
11+
- Enable the addition of custom attributes to `gen_ai.client.inference.operation.details` log events ([#4103](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/4103)).
1112

1213
## Version 0.5b0 (2025-12-11)
1314

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

Lines changed: 28 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,6 @@
2525
AsyncIterator,
2626
Awaitable,
2727
Iterator,
28-
Mapping,
2928
Optional,
3029
Union,
3130
)
@@ -189,7 +188,7 @@ def _to_dict(value: object):
189188
def _create_request_attributes(
190189
config: Optional[GenerateContentConfigOrDict],
191190
allow_list: AllowList,
192-
) -> dict[str, Any]:
191+
) -> dict[str, AttributeValue]:
193192
if not config:
194193
return {}
195194
config = _to_dict(config)
@@ -291,8 +290,8 @@ def _create_completion_details_attributes(
291290
output_messages: list[OutputMessage],
292291
system_instructions: list[MessagePart],
293292
as_str: bool = False,
294-
) -> dict[str, Any]:
295-
attributes: dict[str, Any] = {
293+
) -> dict[str, AttributeValue]:
294+
attributes: dict[str, AttributeValue] = {
296295
gen_ai_attributes.GEN_AI_INPUT_MESSAGES: [
297296
dataclasses.asdict(input_message)
298297
for input_message in input_messages
@@ -310,10 +309,11 @@ def _create_completion_details_attributes(
310309
return attributes
311310

312311

313-
def _get_extra_generate_content_attributes() -> Optional[
314-
Mapping[str, AttributeValue]
315-
]:
316-
return context_api.get_value(GENERATE_CONTENT_EXTRA_ATTRIBUTES_CONTEXT_KEY)
312+
def _get_extra_generate_content_attributes() -> dict[str, AttributeValue]:
313+
attrs = context_api.get_value(
314+
GENERATE_CONTENT_EXTRA_ATTRIBUTES_CONTEXT_KEY
315+
)
316+
return dict(attrs or {})
317317

318318

319319
class _GenerateContentInstrumentationHelper:
@@ -371,7 +371,7 @@ def start_span_as_current_span(
371371
end_on_exit=end_on_exit,
372372
)
373373

374-
def create_final_attributes(self) -> dict[str, Any]:
374+
def create_final_attributes(self) -> dict[str, AttributeValue]:
375375
final_attributes = {
376376
gen_ai_attributes.GEN_AI_USAGE_INPUT_TOKENS: self._input_tokens,
377377
gen_ai_attributes.GEN_AI_USAGE_OUTPUT_TOKENS: self._output_tokens,
@@ -463,8 +463,9 @@ def _maybe_update_error_type(self, response: GenerateContentResponse):
463463

464464
def _maybe_log_completion_details(
465465
self,
466-
request_attributes: dict[str, Any],
467-
final_attributes: dict[str, Any],
466+
extra_attributes: dict[str, AttributeValue],
467+
request_attributes: dict[str, AttributeValue],
468+
final_attributes: dict[str, AttributeValue],
468469
request: Union[ContentListUnion, ContentListUnionDict],
469470
candidates: list[Candidate],
470471
config: Optional[GenerateContentConfigOrDict] = None,
@@ -487,7 +488,9 @@ def _maybe_log_completion_details(
487488
span = trace.get_current_span()
488489
event = LogRecord(
489490
event_name="gen_ai.client.inference.operation.details",
490-
attributes=request_attributes | final_attributes,
491+
attributes=extra_attributes
492+
| request_attributes
493+
| final_attributes,
491494
)
492495
self.completion_hook.on_completion(
493496
inputs=input_messages,
@@ -742,9 +745,8 @@ def instrumented_generate_content(
742745
with helper.start_span_as_current_span(
743746
model, "google.genai.Models.generate_content"
744747
) as span:
745-
if extra_attributes := _get_extra_generate_content_attributes():
746-
span.set_attributes(extra_attributes)
747-
span.set_attributes(request_attributes)
748+
extra_attributes = _get_extra_generate_content_attributes()
749+
span.set_attributes(extra_attributes | request_attributes)
748750
if helper.sem_conv_opt_in_mode == _StabilityMode.DEFAULT:
749751
helper.process_request(contents, config, span)
750752
try:
@@ -773,6 +775,7 @@ def instrumented_generate_content(
773775
final_attributes = helper.create_final_attributes()
774776
span.set_attributes(final_attributes)
775777
helper._maybe_log_completion_details(
778+
extra_attributes,
776779
request_attributes,
777780
final_attributes,
778781
contents,
@@ -817,9 +820,8 @@ def instrumented_generate_content_stream(
817820
with helper.start_span_as_current_span(
818821
model, "google.genai.Models.generate_content_stream"
819822
) as span:
820-
if extra_attributes := _get_extra_generate_content_attributes():
821-
span.set_attributes(extra_attributes)
822-
span.set_attributes(request_attributes)
823+
extra_attributes = _get_extra_generate_content_attributes()
824+
span.set_attributes(extra_attributes | request_attributes)
823825
if helper.sem_conv_opt_in_mode == _StabilityMode.DEFAULT:
824826
helper.process_request(contents, config, span)
825827
try:
@@ -848,6 +850,7 @@ def instrumented_generate_content_stream(
848850
final_attributes = helper.create_final_attributes()
849851
span.set_attributes(final_attributes)
850852
helper._maybe_log_completion_details(
853+
extra_attributes,
851854
request_attributes,
852855
final_attributes,
853856
contents,
@@ -892,9 +895,8 @@ async def instrumented_generate_content(
892895
with helper.start_span_as_current_span(
893896
model, "google.genai.AsyncModels.generate_content"
894897
) as span:
895-
if extra_attributes := _get_extra_generate_content_attributes():
896-
span.set_attributes(extra_attributes)
897-
span.set_attributes(request_attributes)
898+
extra_attributes = _get_extra_generate_content_attributes()
899+
span.set_attributes(extra_attributes | request_attributes)
898900
if helper.sem_conv_opt_in_mode == _StabilityMode.DEFAULT:
899901
helper.process_request(contents, config, span)
900902
try:
@@ -922,6 +924,7 @@ async def instrumented_generate_content(
922924
final_attributes = helper.create_final_attributes()
923925
span.set_attributes(final_attributes)
924926
helper._maybe_log_completion_details(
927+
extra_attributes,
925928
request_attributes,
926929
final_attributes,
927930
contents,
@@ -968,9 +971,8 @@ async def instrumented_generate_content_stream(
968971
"google.genai.AsyncModels.generate_content_stream",
969972
end_on_exit=False,
970973
) as span:
971-
if extra_attributes := _get_extra_generate_content_attributes():
972-
span.set_attributes(extra_attributes)
973-
span.set_attributes(request_attributes)
974+
extra_attributes = _get_extra_generate_content_attributes()
975+
span.set_attributes(extra_attributes | request_attributes)
974976
if (
975977
not helper.sem_conv_opt_in_mode
976978
== _StabilityMode.GEN_AI_LATEST_EXPERIMENTAL
@@ -990,6 +992,7 @@ async def instrumented_generate_content_stream(
990992
final_attributes = helper.create_final_attributes()
991993
span.set_attributes(final_attributes)
992994
helper._maybe_log_completion_details(
995+
extra_attributes,
993996
request_attributes,
994997
final_attributes,
995998
contents,
@@ -1023,6 +1026,7 @@ async def _response_async_generator_wrapper():
10231026
final_attributes = helper.create_final_attributes()
10241027
span.set_attributes(final_attributes)
10251028
helper._maybe_log_completion_details(
1029+
extra_attributes,
10261030
request_attributes,
10271031
final_attributes,
10281032
contents,

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

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -520,6 +520,46 @@ def test_new_semconv_record_completion_in_span(self):
520520

521521
self.tearDown()
522522

523+
def test_new_semconv_log_has_extra_genai_attributes(self):
524+
patched_environ = patch.dict(
525+
"os.environ",
526+
{
527+
"OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT": "EVENT_ONLY",
528+
"OTEL_SEMCONV_STABILITY_OPT_IN": "gen_ai_latest_experimental",
529+
},
530+
)
531+
patched_otel_mapping = patch.dict(
532+
_OpenTelemetrySemanticConventionStability._OTEL_SEMCONV_STABILITY_SIGNAL_MAPPING,
533+
{
534+
_OpenTelemetryStabilitySignalType.GEN_AI: _StabilityMode.GEN_AI_LATEST_EXPERIMENTAL
535+
},
536+
)
537+
with patched_environ, patched_otel_mapping:
538+
self.configure_valid_response(text="Yep, it works!")
539+
tok = context_api.attach(
540+
context_api.set_value(
541+
GENERATE_CONTENT_EXTRA_ATTRIBUTES_CONTEXT_KEY,
542+
{"extra_attribute_key": "extra_attribute_value"},
543+
)
544+
)
545+
try:
546+
self.generate_content(
547+
model="gemini-2.0-flash",
548+
contents="Does this work?",
549+
)
550+
self.otel.assert_has_event_named(
551+
"gen_ai.client.inference.operation.details"
552+
)
553+
event = self.otel.get_event_named(
554+
"gen_ai.client.inference.operation.details"
555+
)
556+
assert (
557+
event.attributes["extra_attribute_key"]
558+
== "extra_attribute_value"
559+
)
560+
finally:
561+
context_api.detach(tok)
562+
523563
def test_records_metrics_data(self):
524564
self.configure_valid_response()
525565
self.generate_content(model="gemini-2.0-flash", contents="Some input")

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

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,14 @@
1313
# limitations under the License.
1414

1515
import unittest
16+
from unittest.mock import patch
1617

1718
from opentelemetry import context as context_api
19+
from opentelemetry.instrumentation._semconv import (
20+
_OpenTelemetrySemanticConventionStability,
21+
_OpenTelemetryStabilitySignalType,
22+
_StabilityMode,
23+
)
1824
from opentelemetry.instrumentation.google_genai import (
1925
GENERATE_CONTENT_EXTRA_ATTRIBUTES_CONTEXT_KEY,
2026
)
@@ -99,3 +105,43 @@ def test_includes_token_counts_in_span_aggregated_from_responses(self):
99105
span = self.otel.get_span_named("generate_content gemini-2.0-flash")
100106
self.assertEqual(span.attributes["gen_ai.usage.input_tokens"], 9)
101107
self.assertEqual(span.attributes["gen_ai.usage.output_tokens"], 12)
108+
109+
def test_new_semconv_log_has_extra_genai_attributes(self):
110+
patched_environ = patch.dict(
111+
"os.environ",
112+
{
113+
"OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT": "EVENT_ONLY",
114+
"OTEL_SEMCONV_STABILITY_OPT_IN": "gen_ai_latest_experimental",
115+
},
116+
)
117+
patched_otel_mapping = patch.dict(
118+
_OpenTelemetrySemanticConventionStability._OTEL_SEMCONV_STABILITY_SIGNAL_MAPPING,
119+
{
120+
_OpenTelemetryStabilitySignalType.GEN_AI: _StabilityMode.GEN_AI_LATEST_EXPERIMENTAL
121+
},
122+
)
123+
with patched_environ, patched_otel_mapping:
124+
self.configure_valid_response(text="Yep, it works!")
125+
tok = context_api.attach(
126+
context_api.set_value(
127+
GENERATE_CONTENT_EXTRA_ATTRIBUTES_CONTEXT_KEY,
128+
{"extra_attribute_key": "extra_attribute_value"},
129+
)
130+
)
131+
try:
132+
self.generate_content(
133+
model="gemini-2.0-flash",
134+
contents="Does this work?",
135+
)
136+
self.otel.assert_has_event_named(
137+
"gen_ai.client.inference.operation.details"
138+
)
139+
event = self.otel.get_event_named(
140+
"gen_ai.client.inference.operation.details"
141+
)
142+
assert (
143+
event.attributes["extra_attribute_key"]
144+
== "extra_attribute_value"
145+
)
146+
finally:
147+
context_api.detach(tok)

0 commit comments

Comments
 (0)