Skip to content

Commit a9f2590

Browse files
RKestcopybara-github
authored andcommitted
fix(otel): Save user.id in opentelemetry-instrumentation-google-genai emitted logs
Prior implementation only covered the natively emitted logs. Co-authored-by: Max Ind <maxind@google.com> PiperOrigin-RevId: 934544198
1 parent 2e3d717 commit a9f2590

6 files changed

Lines changed: 235 additions & 188 deletions

File tree

src/google/adk/telemetry/_experimental_semconv.py

Lines changed: 0 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -581,17 +581,6 @@ def _build_completion_span_attributes(
581581
# ---------------------------------------------------------------------------
582582

583583

584-
def set_operation_details_common_attributes(
585-
operation_details_common_attributes: MutableMapping[str, AttributeValue],
586-
telemetry_config: TelemetryConfig,
587-
attributes: Mapping[str, AttributeValue],
588-
log_only_attributes: Mapping[str, AttributeValue] | None = None,
589-
) -> None:
590-
operation_details_common_attributes.update(attributes)
591-
if log_only_attributes and telemetry_config.should_add_content_to_logs:
592-
operation_details_common_attributes.update(log_only_attributes)
593-
594-
595584
def set_operation_details_attributes_from_request(
596585
operation_details_attributes: MutableMapping[str, AttributeValue],
597586
llm_request: LlmRequest,
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
# Copyright 2026 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
"""Propagation of the ADK session ``user.id`` onto GenAI telemetry records.
16+
17+
``user.id`` is propagated on the OTel context for the duration of an inference
18+
span and copied onto the relevant log records by an installed
19+
``LogRecordProcessor``. This single mechanism serves both inference paths:
20+
21+
* the ADK-native path, where ADK emits the records itself, and
22+
* the delegated path, where ``opentelemetry-instrumentation-google-genai``
23+
owns the span and the records and ignores the keys ADK stashes on the
24+
context, so ADK cannot tag the records directly.
25+
"""
26+
27+
from __future__ import annotations
28+
29+
from collections.abc import Iterator
30+
from collections.abc import MutableMapping
31+
from contextlib import contextmanager
32+
import logging
33+
import threading
34+
from typing import TYPE_CHECKING
35+
36+
from opentelemetry import context as otel_context
37+
from opentelemetry._logs import get_logger_provider
38+
from opentelemetry.context import Context
39+
from opentelemetry.sdk._logs import LogRecordProcessor
40+
from opentelemetry.semconv._incubating.attributes.user_attributes import USER_ID
41+
from typing_extensions import override
42+
43+
from ._experimental_semconv import COMPLETION_DETAILS_EVENT_NAME
44+
from ._stable_semconv import GEN_AI_USER_MESSAGE_EVENT
45+
46+
if TYPE_CHECKING:
47+
from opentelemetry.sdk._logs import ReadWriteLogRecord
48+
49+
from .context import TelemetryConfig
50+
51+
logger = logging.getLogger("google_adk." + __name__)
52+
53+
# Unique, process-stable key under which the user id is stashed on the OTel
54+
# context. ``create_key`` appends a uuid, so the key cannot collide with keys
55+
# created elsewhere.
56+
_USER_ID_CONTEXT_KEY = otel_context.create_key("adk-gen-ai-user-id")
57+
58+
# Event names whose records carry user-authored content and should therefore be
59+
# tagged with ``user.id``. Other records (e.g. ``gen_ai.system.message``,
60+
# ``gen_ai.choice``) are deliberately left untouched so PII is not sprayed
61+
# across every emitted record.
62+
_USER_ID_EVENT_ALLOWLIST = frozenset({
63+
GEN_AI_USER_MESSAGE_EVENT,
64+
COMPLETION_DETAILS_EVENT_NAME,
65+
})
66+
67+
# Guards a single global install of the LogRecordProcessor.
68+
_install_lock = threading.Lock()
69+
_processor_installed = False
70+
71+
72+
@contextmanager
73+
def maybe_propagate_user_id_to_records(
74+
user_id: str | None,
75+
telemetry_config: TelemetryConfig,
76+
) -> Iterator[None]:
77+
"""Stashes ``user_id`` on the OTel context for the user-id LogRecordProcessor.
78+
79+
Wraps the whole inference span (both the ADK-native and the delegated paths).
80+
The installed ``_UserIdLogRecordProcessor`` reads the value back off each log
81+
record's captured context and copies it onto ``user.id``. A no-op when there
82+
is no user id or when the per-request config disables content-bearing logs, so
83+
``user.id`` is only attached when message content is also being captured.
84+
"""
85+
_maybe_install_log_record_processor()
86+
87+
if user_id is None or not telemetry_config.should_add_content_to_logs:
88+
yield
89+
return
90+
token = otel_context.attach(
91+
otel_context.set_value(_USER_ID_CONTEXT_KEY, user_id)
92+
)
93+
try:
94+
yield
95+
finally:
96+
otel_context.detach(token)
97+
98+
99+
def _get_from_context(context: Context | None) -> str | None:
100+
"""Type-safe read of the propagated user id from ``context``."""
101+
value = otel_context.get_value(_USER_ID_CONTEXT_KEY, context)
102+
return value if isinstance(value, str) else None
103+
104+
105+
class _UserIdLogRecordProcessor(LogRecordProcessor):
106+
"""Copies the context-propagated ``user.id`` onto allowlisted log records.
107+
108+
The records are emitted while the user-id context is active, and the OTel
109+
``LogRecord`` snapshots that context at construction, so the user id is
110+
recoverable here from the record's captured context. Records emitted outside
111+
an active user-id context, or whose event name is not allowlisted, are left
112+
untouched.
113+
"""
114+
115+
@override
116+
def on_emit(self, log_record: ReadWriteLogRecord) -> None:
117+
record = log_record.log_record
118+
if record.event_name not in _USER_ID_EVENT_ALLOWLIST:
119+
return
120+
user_id = _get_from_context(record.context)
121+
if user_id is None:
122+
return
123+
124+
if isinstance(record.attributes, MutableMapping):
125+
record.attributes[USER_ID] = user_id
126+
else:
127+
record.attributes = {
128+
**(record.attributes or {}),
129+
USER_ID: user_id,
130+
}
131+
132+
@override
133+
def shutdown(self) -> None:
134+
pass
135+
136+
@override
137+
def force_flush(self, timeout_millis: int = 30000) -> bool:
138+
return True
139+
140+
141+
def _maybe_install_log_record_processor() -> None:
142+
"""Installs the user-id LogRecordProcessor once for the process.
143+
144+
Idempotent: a no-op after the first successful install. Also a no-op while the
145+
global logger provider is still the API-only no-op/proxy provider (which has
146+
no ``add_log_record_processor``); in that case a later call retries once an
147+
SDK logger provider is configured.
148+
"""
149+
global _processor_installed
150+
if _processor_installed:
151+
return
152+
with _install_lock:
153+
if _processor_installed:
154+
return
155+
provider = get_logger_provider()
156+
add_processor = getattr(provider, "add_log_record_processor", None)
157+
if add_processor is None:
158+
return
159+
add_processor(_UserIdLogRecordProcessor())
160+
_processor_installed = True

src/google/adk/telemetry/tracing.py

Lines changed: 43 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,6 @@
5050
from opentelemetry.semconv._incubating.attributes.gen_ai_attributes import GEN_AI_TOOL_NAME
5151
from opentelemetry.semconv._incubating.attributes.gen_ai_attributes import GEN_AI_TOOL_TYPE
5252
from opentelemetry.semconv._incubating.attributes.gen_ai_attributes import GenAiSystemValues
53-
from opentelemetry.semconv._incubating.attributes.user_attributes import USER_ID
5453
from opentelemetry.semconv.attributes.error_attributes import ERROR_TYPE
5554
from opentelemetry.semconv.schemas import Schemas
5655
from opentelemetry.trace import Span
@@ -63,7 +62,6 @@
6362
from ._experimental_semconv import maybe_log_completion_details
6463
from ._experimental_semconv import set_operation_details_attributes_from_request
6564
from ._experimental_semconv import set_operation_details_attributes_from_response
66-
from ._experimental_semconv import set_operation_details_common_attributes
6765
from ._serialization import safe_json_serialize
6866
from ._stable_semconv import choice_body
6967
from ._stable_semconv import GEN_AI_CHOICE_EVENT
@@ -74,6 +72,7 @@
7472
from ._stable_semconv import USER_CONTENT_ELIDED
7573
from ._stable_semconv import user_message_body
7674
from ._token_usage import TokenUsage
75+
from ._user_id import maybe_propagate_user_id_to_records as _maybe_propagate_user_id_to_records
7776
from .context import TelemetryConfig
7877

7978
# By default some ADK spans include attributes with potential PII data.
@@ -567,23 +566,19 @@ def use_generate_content_span(
567566
"gcp.vertex.agent.event_id": model_response_event.id,
568567
"gcp.vertex.agent.invocation_id": invocation_context.invocation_id,
569568
}
570-
log_only_common_attributes = {}
571-
if invocation_context.session.user_id is not None:
572-
log_only_common_attributes[USER_ID] = invocation_context.session.user_id
573-
if _should_emit_native_telemetry(invocation_context.agent):
574-
with _use_native_generate_content_span_stable_semconv(
575-
llm_request=llm_request,
576-
common_attributes=common_attributes,
577-
log_only_common_attributes=log_only_common_attributes,
578-
telemetry_config=telemetry_config,
579-
) as span:
580-
yield span.span
581-
else:
582-
with _use_extra_generate_content_attributes(
583-
common_attributes,
584-
log_only_extra_attributes=log_only_common_attributes,
585-
):
586-
yield
569+
with _maybe_propagate_user_id_to_records(
570+
invocation_context.session.user_id, telemetry_config
571+
):
572+
if _should_emit_native_telemetry(invocation_context.agent):
573+
with _use_native_generate_content_span_stable_semconv(
574+
llm_request=llm_request,
575+
common_attributes=common_attributes,
576+
telemetry_config=telemetry_config,
577+
) as span:
578+
yield span.span
579+
else:
580+
with _use_extra_generate_content_attributes(common_attributes):
581+
yield
587582

588583

589584
@asynccontextmanager
@@ -608,39 +603,35 @@ async def use_inference_span(
608603
"gcp.vertex.agent.event_id": model_response_event.id,
609604
"gcp.vertex.agent.invocation_id": invocation_context.invocation_id,
610605
}
611-
log_only_common_attributes = {}
612-
if invocation_context.session.user_id is not None:
613-
log_only_common_attributes[USER_ID] = invocation_context.session.user_id
614-
if _should_emit_native_telemetry(invocation_context.agent):
615-
async with _use_native_generate_content_span(
616-
llm_request=llm_request,
617-
common_attributes=common_attributes,
618-
log_only_common_attributes=log_only_common_attributes,
619-
telemetry_config=telemetry_config,
620-
) as gc_span:
621-
if telemetry_config.should_use_experimental_genai_semconv:
622-
set_operation_details_common_attributes(
623-
gc_span.operation_details_common_attributes,
624-
telemetry_config,
625-
common_attributes,
626-
log_only_attributes=log_only_common_attributes,
627-
)
628-
try:
629-
yield gc_span
630-
finally:
631-
maybe_log_completion_details(
632-
gc_span.span,
633-
otel_logger,
634-
gc_span.operation_details_attributes,
635-
gc_span.operation_details_common_attributes,
636-
telemetry_config,
637-
)
638-
else:
639-
with _use_extra_generate_content_attributes(
640-
common_attributes,
641-
log_only_extra_attributes=log_only_common_attributes,
642-
):
643-
yield
606+
# user.id is propagated on the OTel context and copied onto the relevant log
607+
# records by the installed LogRecordProcessor (see ._user_id). This is the
608+
# single mechanism for both the ADK-native and the delegated inference paths;
609+
# on the delegated path the genai instrumentation library owns the records, so
610+
# ADK cannot set the attribute directly.
611+
with _maybe_propagate_user_id_to_records(
612+
invocation_context.session.user_id, telemetry_config
613+
):
614+
if _should_emit_native_telemetry(invocation_context.agent):
615+
async with _use_native_generate_content_span(
616+
llm_request=llm_request,
617+
common_attributes=common_attributes,
618+
telemetry_config=telemetry_config,
619+
) as gc_span:
620+
if telemetry_config.should_use_experimental_genai_semconv:
621+
gc_span.operation_details_common_attributes.update(common_attributes)
622+
try:
623+
yield gc_span
624+
finally:
625+
maybe_log_completion_details(
626+
gc_span.span,
627+
otel_logger,
628+
gc_span.operation_details_attributes,
629+
gc_span.operation_details_common_attributes,
630+
telemetry_config,
631+
)
632+
else:
633+
with _use_extra_generate_content_attributes(common_attributes):
634+
yield
644635

645636

646637
def _instrumented_with_opentelemetry_instrumentation_google_genai() -> bool:
@@ -670,7 +661,6 @@ def _should_emit_native_telemetry(agent: BaseAgent) -> bool:
670661
@contextmanager
671662
def _use_extra_generate_content_attributes(
672663
extra_attributes: Mapping[str, AttributeValue],
673-
log_only_extra_attributes: Mapping[str, AttributeValue] | None = None,
674664
):
675665
try:
676666
from opentelemetry.instrumentation.google_genai import GENERATE_CONTENT_EXTRA_ATTRIBUTES_CONTEXT_KEY
@@ -688,18 +678,6 @@ def _use_extra_generate_content_attributes(
688678
ctx = otel_context.set_value(
689679
GENERATE_CONTENT_EXTRA_ATTRIBUTES_CONTEXT_KEY, extra_attributes
690680
)
691-
if log_only_extra_attributes:
692-
try:
693-
from opentelemetry.instrumentation.google_genai import GENERATE_CONTENT_EVENT_ONLY_EXTRA_ATTRIBUTES_CONTEXT_KEY
694-
695-
ctx = otel_context.set_value(
696-
GENERATE_CONTENT_EVENT_ONLY_EXTRA_ATTRIBUTES_CONTEXT_KEY,
697-
log_only_extra_attributes,
698-
context=ctx,
699-
)
700-
except (ImportError, AttributeError):
701-
pass
702-
703681
tok = otel_context.attach(ctx)
704682
try:
705683
yield
@@ -732,7 +710,6 @@ def _set_common_generate_content_attributes(
732710
def _use_native_generate_content_span_stable_semconv(
733711
llm_request: LlmRequest,
734712
common_attributes: Mapping[str, AttributeValue],
735-
log_only_common_attributes: Mapping[str, AttributeValue] | None = None,
736713
telemetry_config: TelemetryConfig | None = None,
737714
) -> Iterator[GenerateContentSpan]:
738715
telemetry_config = telemetry_config or TelemetryConfig()
@@ -753,13 +730,6 @@ def _use_native_generate_content_span_stable_semconv(
753730
)
754731
)
755732
user_message_attributes = {GEN_AI_SYSTEM: _guess_gemini_system_name()}
756-
if (
757-
telemetry_config.should_add_content_to_logs
758-
and log_only_common_attributes
759-
):
760-
user_id = log_only_common_attributes.get(USER_ID)
761-
if user_id is not None:
762-
user_message_attributes[USER_ID] = user_id
763733

764734
for content in llm_request.contents:
765735
otel_logger.emit(
@@ -778,13 +748,11 @@ async def _use_native_generate_content_span(
778748
llm_request: LlmRequest,
779749
common_attributes: Mapping[str, AttributeValue],
780750
telemetry_config: TelemetryConfig,
781-
log_only_common_attributes: Mapping[str, AttributeValue] | None = None,
782751
) -> AsyncIterator[GenerateContentSpan]:
783752
if not telemetry_config.should_use_experimental_genai_semconv:
784753
with _use_native_generate_content_span_stable_semconv(
785754
llm_request,
786755
common_attributes,
787-
log_only_common_attributes=log_only_common_attributes,
788756
telemetry_config=telemetry_config,
789757
) as gc_span:
790758
yield gc_span

tests/unittests/telemetry/functional_test_helpers.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@
4949
from google.adk.runners import InMemoryRunner
5050
from google.adk.telemetry import node_tracing
5151
from google.adk.telemetry import tracing
52+
from google.adk.telemetry._user_id import _UserIdLogRecordProcessor
5253
from google.adk.tools.function_tool import FunctionTool
5354
from google.adk.workflow._base_node import START
5455
from google.adk.workflow._workflow import Workflow
@@ -315,6 +316,11 @@ def install_telemetry(
315316
)
316317

317318
logger_provider = LoggerProvider()
319+
# Tag records with user.id before they are exported, mirroring the
320+
# process-global install done by maybe_install_log_record_processor(). It must
321+
# run before the exporting processor since SimpleLogRecordProcessor exports
322+
# synchronously in on_emit.
323+
logger_provider.add_log_record_processor(_UserIdLogRecordProcessor())
318324
logger_provider.add_log_record_processor(
319325
SimpleLogRecordProcessor(log_exporter)
320326
)

0 commit comments

Comments
 (0)