Skip to content

Commit aab3512

Browse files
committed
[google-genai] Provide a way to attach extra attributes to the operation-details event but not to the span
Add GENERATE_CONTENT_EVENT_ONLY_EXTRA_ATTRIBUTES_CONTEXT_KEY for caller-supplied attributes that should be emitted only on the gen_ai.client.inference.operation.details log event and never on the generate_content {model} span. Threaded through all four wrappers (sync/async x streaming/non-streaming). Precedence on the event: caller-supplied extra_attributes, then event-only extra_attributes (so they win over extra_attributes on collision), then request_attributes and final_attributes. Putting the instrumentation-owned semconv fields last ensures callers cannot accidentally clobber them (e.g. gen_ai.usage.input_tokens) via the event-only context value. The span continues to carry only extra_attributes for any collisions; event-only attributes are never set on the span. Tests cover sync/async x streaming/non-streaming for: event-only attributes not appearing on the span, the event-only-vs-extra-attributes collision on the event, and event-only not overriding semconv fields. Originally proposed at open-telemetry/opentelemetry-python-contrib#4581 before the package moved to this repo. Assisted-by: opencode agent
1 parent 7018efc commit aab3512

5 files changed

Lines changed: 339 additions & 1 deletion

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
`opentelemetry-instrumentation-google-genai`: Add `GENERATE_CONTENT_EVENT_ONLY_EXTRA_ATTRIBUTES_CONTEXT_KEY` for attaching caller-supplied attributes that are emitted only on the `gen_ai.client.inference.operation.details` log event and never on the `generate_content {model}` span. On key collisions with `GENERATE_CONTENT_EXTRA_ATTRIBUTES_CONTEXT_KEY`, the event-only value wins on the event.

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

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,12 +30,16 @@
3030
---
3131
"""
3232

33-
from .generate_content import GENERATE_CONTENT_EXTRA_ATTRIBUTES_CONTEXT_KEY
33+
from .generate_content import (
34+
GENERATE_CONTENT_EVENT_ONLY_EXTRA_ATTRIBUTES_CONTEXT_KEY,
35+
GENERATE_CONTENT_EXTRA_ATTRIBUTES_CONTEXT_KEY,
36+
)
3437
from .instrumentor import GoogleGenAiSdkInstrumentor
3538
from .version import __version__
3639

3740
__all__ = [
3841
"GoogleGenAiSdkInstrumentor",
3942
"GENERATE_CONTENT_EXTRA_ATTRIBUTES_CONTEXT_KEY",
43+
"GENERATE_CONTENT_EVENT_ONLY_EXTRA_ATTRIBUTES_CONTEXT_KEY",
4044
"__version__",
4145
]

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

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,20 @@
101101
"generate_content_extra_attributes_context_key"
102102
)
103103

104+
# Attributes attached under this context key are emitted only on the
105+
# `gen_ai.client.inference.operation.details` log event; they are NEVER
106+
# attached to the `generate_content {model}` span. Use this for caller-supplied
107+
# attributes that must not land on broadly-sampled spans -- for example, an
108+
# end-user identifier that is acceptable in telemetry log events but
109+
# undesirable on spans. On key collisions with values supplied via
110+
# ``GENERATE_CONTENT_EXTRA_ATTRIBUTES_CONTEXT_KEY``, the event-only value wins
111+
# on the event.
112+
GENERATE_CONTENT_EVENT_ONLY_EXTRA_ATTRIBUTES_CONTEXT_KEY = (
113+
context_api.create_key(
114+
"generate_content_event_only_extra_attributes_context_key"
115+
)
116+
)
117+
104118

105119
class _MethodsSnapshot:
106120
def __init__(self):
@@ -495,6 +509,15 @@ def _get_extra_generate_content_attributes() -> dict[str, AttributeValue]:
495509
return dict(attrs or {})
496510

497511

512+
def _get_event_only_extra_generate_content_attributes() -> dict[
513+
str, AttributeValue
514+
]:
515+
attrs = context_api.get_value(
516+
GENERATE_CONTENT_EVENT_ONLY_EXTRA_ATTRIBUTES_CONTEXT_KEY
517+
)
518+
return dict(attrs or {})
519+
520+
498521
class _GenerateContentInstrumentationHelper:
499522
def __init__(
500523
self,
@@ -750,6 +773,9 @@ def _maybe_log_completion_details(
750773
candidates: list[Candidate],
751774
config: Optional[GenerateContentConfigOrDict] = None,
752775
tool_definitions: Optional[list[ToolDefinition]] = None,
776+
event_only_extra_attributes: Optional[
777+
dict[str, AttributeValue]
778+
] = None,
753779
):
754780
if not self.experimental_sem_convs_enabled:
755781
return
@@ -763,9 +789,16 @@ def _maybe_log_completion_details(
763789
)
764790
output_messages = to_output_messages(candidates=candidates)
765791
span = trace.get_current_span()
792+
# event_only_extra_attributes win on the event when colliding with
793+
# extra_attributes (caller-supplied), but instrumentation-owned
794+
# request_attributes/final_attributes (semconv fields) always take
795+
# precedence so callers cannot accidentally clobber them. They are
796+
# also intentionally NOT set on the span (the caller did not include
797+
# them in the span.set_attributes() call).
766798
event = LogRecord(
767799
event_name="gen_ai.client.inference.operation.details",
768800
attributes=extra_attributes
801+
| (event_only_extra_attributes or {})
769802
| request_attributes
770803
| final_attributes,
771804
)
@@ -1032,6 +1065,10 @@ def instrumented_generate_content(
10321065
model, "google.genai.Models.generate_content"
10331066
) as span:
10341067
extra_attributes = _get_extra_generate_content_attributes()
1068+
event_only_extra_attributes = (
1069+
_get_event_only_extra_generate_content_attributes()
1070+
)
1071+
# event_only_extra_attributes are intentionally excluded from the span.
10351072
span.set_attributes(extra_attributes | request_attributes)
10361073
if not helper.experimental_sem_convs_enabled:
10371074
helper.process_request(contents, config, span)
@@ -1068,6 +1105,7 @@ def instrumented_generate_content(
10681105
candidates,
10691106
config,
10701107
maybe_tool_definitions,
1108+
event_only_extra_attributes=event_only_extra_attributes,
10711109
)
10721110
helper._record_token_usage_metric()
10731111
helper._record_duration_metric()
@@ -1109,6 +1147,10 @@ def instrumented_generate_content_stream(
11091147
model, "google.genai.Models.generate_content_stream"
11101148
) as span:
11111149
extra_attributes = _get_extra_generate_content_attributes()
1150+
event_only_extra_attributes = (
1151+
_get_event_only_extra_generate_content_attributes()
1152+
)
1153+
# event_only_extra_attributes are intentionally excluded from the span.
11121154
span.set_attributes(extra_attributes | request_attributes)
11131155
if not helper.experimental_sem_convs_enabled:
11141156
helper.process_request(contents, config, span)
@@ -1145,6 +1187,7 @@ def instrumented_generate_content_stream(
11451187
candidates,
11461188
config,
11471189
maybe_tool_definitions,
1190+
event_only_extra_attributes=event_only_extra_attributes,
11481191
)
11491192
helper._record_token_usage_metric()
11501193
helper._record_duration_metric()
@@ -1186,6 +1229,10 @@ async def instrumented_generate_content(
11861229
model, "google.genai.AsyncModels.generate_content"
11871230
) as span:
11881231
extra_attributes = _get_extra_generate_content_attributes()
1232+
event_only_extra_attributes = (
1233+
_get_event_only_extra_generate_content_attributes()
1234+
)
1235+
# event_only_extra_attributes are intentionally excluded from the span.
11891236
span.set_attributes(extra_attributes | request_attributes)
11901237
if not helper.experimental_sem_convs_enabled:
11911238
helper.process_request(contents, config, span)
@@ -1221,6 +1268,7 @@ async def instrumented_generate_content(
12211268
candidates,
12221269
config,
12231270
maybe_tool_definitions,
1271+
event_only_extra_attributes=event_only_extra_attributes,
12241272
)
12251273
helper._record_token_usage_metric()
12261274
helper._record_duration_metric()
@@ -1264,6 +1312,10 @@ async def instrumented_generate_content_stream(
12641312
end_on_exit=False,
12651313
) as span:
12661314
extra_attributes = _get_extra_generate_content_attributes()
1315+
event_only_extra_attributes = (
1316+
_get_event_only_extra_generate_content_attributes()
1317+
)
1318+
# event_only_extra_attributes are intentionally excluded from the span.
12671319
span.set_attributes(extra_attributes | request_attributes)
12681320
if not helper.experimental_sem_convs_enabled:
12691321
helper.process_request(contents, config, span)
@@ -1291,6 +1343,7 @@ async def instrumented_generate_content_stream(
12911343
[],
12921344
config,
12931345
maybe_tool_definitions,
1346+
event_only_extra_attributes=event_only_extra_attributes,
12941347
)
12951348
helper._record_duration_metric()
12961349
with trace.use_span(span, end_on_exit=True):
@@ -1328,6 +1381,7 @@ async def _response_async_generator_wrapper():
13281381
candidates,
13291382
config,
13301383
maybe_tool_definitions,
1384+
event_only_extra_attributes=event_only_extra_attributes,
13311385
)
13321386
helper._record_token_usage_metric()
13331387
helper._record_duration_metric()

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

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
_StabilityMode,
2424
)
2525
from opentelemetry.instrumentation.google_genai import (
26+
GENERATE_CONTENT_EVENT_ONLY_EXTRA_ATTRIBUTES_CONTEXT_KEY,
2627
GENERATE_CONTENT_EXTRA_ATTRIBUTES_CONTEXT_KEY,
2728
)
2829
from opentelemetry.semconv._incubating.attributes import gen_ai_attributes
@@ -925,6 +926,146 @@ def test_new_semconv_log_has_extra_genai_attributes(self):
925926
finally:
926927
context_api.detach(tok)
927928

929+
def test_event_only_extra_attributes_not_set_on_span(self):
930+
"""event_only_extra_attributes must never appear on the span attributes."""
931+
self.configure_valid_response(text="Yep, it works!")
932+
tok = context_api.attach(
933+
context_api.set_value(
934+
GENERATE_CONTENT_EVENT_ONLY_EXTRA_ATTRIBUTES_CONTEXT_KEY,
935+
{"user.id": "user-42"},
936+
)
937+
)
938+
try:
939+
self.generate_content(
940+
model="gemini-2.0-flash", contents="Does this work?"
941+
)
942+
span = self.otel.get_span_named(
943+
"generate_content gemini-2.0-flash"
944+
)
945+
self.assertNotIn("user.id", span.attributes)
946+
finally:
947+
context_api.detach(tok)
948+
949+
def test_event_only_extra_attributes_set_on_event_only(self):
950+
"""event_only_extra_attributes land on the operation-details event but not on the span.
951+
952+
Also verifies the collision-precedence rule: when a key appears in both
953+
``GENERATE_CONTENT_EXTRA_ATTRIBUTES_CONTEXT_KEY`` and
954+
``GENERATE_CONTENT_EVENT_ONLY_EXTRA_ATTRIBUTES_CONTEXT_KEY``, the
955+
event-only value wins on the event, while the span carries the
956+
``extra_attributes`` value (event-only is never on the span).
957+
"""
958+
patched_environ = patch.dict(
959+
"os.environ",
960+
{
961+
"OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT": "EVENT_ONLY",
962+
"OTEL_SEMCONV_STABILITY_OPT_IN": "gen_ai_latest_experimental",
963+
},
964+
)
965+
patched_otel_mapping = patch.dict(
966+
_OpenTelemetrySemanticConventionStability._OTEL_SEMCONV_STABILITY_SIGNAL_MAPPING,
967+
{
968+
_OpenTelemetryStabilitySignalType.GEN_AI: _StabilityMode.GEN_AI_LATEST_EXPERIMENTAL
969+
},
970+
)
971+
with patched_environ, patched_otel_mapping:
972+
self.configure_valid_response(text="Yep, it works!")
973+
tok_extra = context_api.attach(
974+
context_api.set_value(
975+
GENERATE_CONTENT_EXTRA_ATTRIBUTES_CONTEXT_KEY,
976+
{"shared.key": "from_extra"},
977+
)
978+
)
979+
tok_event_only = context_api.attach(
980+
context_api.set_value(
981+
GENERATE_CONTENT_EVENT_ONLY_EXTRA_ATTRIBUTES_CONTEXT_KEY,
982+
{
983+
"user.id": "user-42",
984+
"shared.key": "from_event_only",
985+
},
986+
)
987+
)
988+
try:
989+
self.generate_content(
990+
model="gemini-2.0-flash",
991+
contents="Does this work?",
992+
)
993+
994+
span = self.otel.get_span_named(
995+
"generate_content gemini-2.0-flash"
996+
)
997+
self.assertNotIn("user.id", span.attributes)
998+
# On the span, only `extra_attributes` contributes the shared key.
999+
self.assertEqual(span.attributes["shared.key"], "from_extra")
1000+
1001+
self.otel.assert_has_event_named(
1002+
"gen_ai.client.inference.operation.details"
1003+
)
1004+
event = self.otel.get_event_named(
1005+
"gen_ai.client.inference.operation.details"
1006+
)
1007+
self.assertEqual(event.attributes["user.id"], "user-42")
1008+
# On the event, event_only wins on the collision.
1009+
self.assertEqual(
1010+
event.attributes["shared.key"], "from_event_only"
1011+
)
1012+
finally:
1013+
context_api.detach(tok_event_only)
1014+
context_api.detach(tok_extra)
1015+
1016+
def test_event_only_extra_attributes_do_not_override_semconv_attributes(
1017+
self,
1018+
):
1019+
"""event_only_extra_attributes must never override instrumentation-owned semconv attributes.
1020+
1021+
Callers should not be able to clobber attributes set by the
1022+
instrumentation itself (request_attributes / final_attributes) via the
1023+
event-only context value, even on the event payload.
1024+
"""
1025+
patched_environ = patch.dict(
1026+
"os.environ",
1027+
{
1028+
"OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT": "EVENT_ONLY",
1029+
"OTEL_SEMCONV_STABILITY_OPT_IN": "gen_ai_latest_experimental",
1030+
},
1031+
)
1032+
patched_otel_mapping = patch.dict(
1033+
_OpenTelemetrySemanticConventionStability._OTEL_SEMCONV_STABILITY_SIGNAL_MAPPING,
1034+
{
1035+
_OpenTelemetryStabilitySignalType.GEN_AI: _StabilityMode.GEN_AI_LATEST_EXPERIMENTAL
1036+
},
1037+
)
1038+
with patched_environ, patched_otel_mapping:
1039+
self.configure_valid_response(text="Yep, it works!")
1040+
tok_event_only = context_api.attach(
1041+
context_api.set_value(
1042+
GENERATE_CONTENT_EVENT_ONLY_EXTRA_ATTRIBUTES_CONTEXT_KEY,
1043+
{
1044+
# Collide with a final_attributes (semconv) key.
1045+
gen_ai_attributes.GEN_AI_USAGE_INPUT_TOKENS: -1,
1046+
},
1047+
)
1048+
)
1049+
try:
1050+
self.generate_content(
1051+
model="gemini-2.0-flash",
1052+
contents="Does this work?",
1053+
)
1054+
1055+
event = self.otel.get_event_named(
1056+
"gen_ai.client.inference.operation.details"
1057+
)
1058+
# The instrumentation-owned semconv value must win, not the
1059+
# caller-supplied event-only value.
1060+
self.assertNotEqual(
1061+
event.attributes[
1062+
gen_ai_attributes.GEN_AI_USAGE_INPUT_TOKENS
1063+
],
1064+
-1,
1065+
)
1066+
finally:
1067+
context_api.detach(tok_event_only)
1068+
9281069
def test_records_metrics_data(self):
9291070
self.configure_valid_response()
9301071
self.generate_content(model="gemini-2.0-flash", contents="Some input")

0 commit comments

Comments
 (0)