Skip to content

Commit c0bc2c9

Browse files
authored
Add Vertex gen AI response attributes and gen_ai.choice events (#3227)
* Add Vertex gen AI response span attributes * Vertex response gen_ai.choice events * Add todo comment * Update _map_finish_reason() and use it in span attribute as well * ruff
1 parent 64f28ca commit c0bc2c9

File tree

7 files changed

+241
-27
lines changed

7 files changed

+241
-27
lines changed

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

+2
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,5 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1515
([#3208](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3208))
1616
- VertexAI emit user, system, and assistant events
1717
([#3203](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3203))
18+
- Add Vertex gen AI response span attributes
19+
([#3227](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3227))

instrumentation-genai/opentelemetry-instrumentation-vertexai/src/opentelemetry/instrumentation/vertexai/events.py

+48
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,11 @@
2020
schematized in YAML and the Weaver tool supports it.
2121
"""
2222

23+
from __future__ import annotations
24+
25+
from dataclasses import asdict, dataclass
26+
from typing import Literal
27+
2328
from opentelemetry._events import Event
2429
from opentelemetry.semconv._incubating.attributes import gen_ai_attributes
2530
from opentelemetry.util.types import AnyValue
@@ -89,3 +94,46 @@ def system_event(
8994
},
9095
body=body,
9196
)
97+
98+
99+
@dataclass
100+
class ChoiceMessage:
101+
"""The message field for a gen_ai.choice event"""
102+
103+
content: AnyValue = None
104+
role: str = "assistant"
105+
106+
107+
FinishReason = Literal[
108+
"content_filter", "error", "length", "stop", "tool_calls"
109+
]
110+
111+
112+
# TODO add tool calls
113+
# https://github.com/open-telemetry/opentelemetry-python-contrib/issues/3216
114+
def choice_event(
115+
*,
116+
finish_reason: FinishReason | str,
117+
index: int,
118+
message: ChoiceMessage,
119+
) -> Event:
120+
"""Creates a choice event, which describes the Gen AI response message.
121+
https://github.com/open-telemetry/semantic-conventions/blob/v1.28.0/docs/gen-ai/gen-ai-events.md#event-gen_aichoice
122+
"""
123+
body: dict[str, AnyValue] = {
124+
"finish_reason": finish_reason,
125+
"index": index,
126+
"message": asdict(
127+
message,
128+
# filter nulls
129+
dict_factory=lambda kvs: {k: v for (k, v) in kvs if v is not None},
130+
),
131+
}
132+
133+
return Event(
134+
name="gen_ai.choice",
135+
attributes={
136+
gen_ai_attributes.GEN_AI_SYSTEM: gen_ai_attributes.GenAiSystemValues.VERTEX_AI.value,
137+
},
138+
body=body,
139+
)

instrumentation-genai/opentelemetry-instrumentation-vertexai/src/opentelemetry/instrumentation/vertexai/patch.py

+12-7
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,11 @@
2525
from opentelemetry.instrumentation.vertexai.utils import (
2626
GenerateContentParams,
2727
get_genai_request_attributes,
28+
get_genai_response_attributes,
2829
get_server_attributes,
2930
get_span_name,
3031
request_to_events,
32+
response_to_events,
3133
)
3234
from opentelemetry.trace import SpanKind, Tracer
3335

@@ -113,25 +115,28 @@ def traced_method(
113115
name=span_name,
114116
kind=SpanKind.CLIENT,
115117
attributes=span_attributes,
116-
) as _span:
118+
) as span:
117119
for event in request_to_events(
118120
params=params, capture_content=capture_content
119121
):
120122
event_logger.emit(event)
121123

122124
# TODO: set error.type attribute
123125
# https://github.com/open-telemetry/semantic-conventions/blob/main/docs/gen-ai/gen-ai-spans.md
124-
result = wrapped(*args, **kwargs)
126+
response = wrapped(*args, **kwargs)
125127
# TODO: handle streaming
126128
# if is_streaming(kwargs):
127129
# return StreamWrapper(
128130
# result, span, event_logger, capture_content
129131
# )
130132

131-
# TODO: add response attributes and events
132-
# _set_response_attributes(
133-
# span, result, event_logger, capture_content
134-
# )
135-
return result
133+
if span.is_recording():
134+
span.set_attributes(get_genai_response_attributes(response))
135+
for event in response_to_events(
136+
response=response, capture_content=capture_content
137+
):
138+
event_logger.emit(event)
139+
140+
return response
136141

137142
return traced_method

instrumentation-genai/opentelemetry-instrumentation-vertexai/src/opentelemetry/instrumentation/vertexai/utils.py

+73-2
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,10 @@
2828

2929
from opentelemetry._events import Event
3030
from opentelemetry.instrumentation.vertexai.events import (
31+
ChoiceMessage,
32+
FinishReason,
3133
assistant_event,
34+
choice_event,
3235
system_event,
3336
user_event,
3437
)
@@ -39,15 +42,25 @@
3942
from opentelemetry.util.types import AnyValue, AttributeValue
4043

4144
if TYPE_CHECKING:
42-
from google.cloud.aiplatform_v1.types import content, tool
45+
from google.cloud.aiplatform_v1.types import (
46+
content,
47+
prediction_service,
48+
tool,
49+
)
4350
from google.cloud.aiplatform_v1beta1.types import (
4451
content as content_v1beta1,
4552
)
53+
from google.cloud.aiplatform_v1beta1.types import (
54+
prediction_service as prediction_service_v1beta1,
55+
)
4656
from google.cloud.aiplatform_v1beta1.types import (
4757
tool as tool_v1beta1,
4858
)
4959

5060

61+
_MODEL = "model"
62+
63+
5164
@dataclass(frozen=True)
5265
class GenerateContentParams:
5366
model: str
@@ -137,6 +150,24 @@ def get_genai_request_attributes(
137150
return attributes
138151

139152

153+
def get_genai_response_attributes(
154+
response: prediction_service.GenerateContentResponse
155+
| prediction_service_v1beta1.GenerateContentResponse,
156+
) -> dict[str, AttributeValue]:
157+
finish_reasons: list[str] = [
158+
_map_finish_reason(candidate.finish_reason)
159+
for candidate in response.candidates
160+
]
161+
# TODO: add gen_ai.response.id once available in the python client
162+
# https://github.com/open-telemetry/opentelemetry-python-contrib/issues/3246
163+
return {
164+
GenAIAttributes.GEN_AI_RESPONSE_MODEL: response.model_version,
165+
GenAIAttributes.GEN_AI_RESPONSE_FINISH_REASONS: finish_reasons,
166+
GenAIAttributes.GEN_AI_USAGE_INPUT_TOKENS: response.usage_metadata.prompt_token_count,
167+
GenAIAttributes.GEN_AI_USAGE_OUTPUT_TOKENS: response.usage_metadata.candidates_token_count,
168+
}
169+
170+
140171
_MODEL_STRIP_RE = re.compile(
141172
r"^projects/(.*)/locations/(.*)/publishers/google/models/"
142173
)
@@ -182,7 +213,7 @@ def request_to_events(
182213

183214
for content in params.contents or []:
184215
# Assistant message
185-
if content.role == "model":
216+
if content.role == _MODEL:
186217
request_content = _parts_to_any_value(
187218
capture_content=capture_content, parts=content.parts
188219
)
@@ -196,6 +227,27 @@ def request_to_events(
196227
yield user_event(role=content.role, content=request_content)
197228

198229

230+
def response_to_events(
231+
*,
232+
response: prediction_service.GenerateContentResponse
233+
| prediction_service_v1beta1.GenerateContentResponse,
234+
capture_content: bool,
235+
) -> Iterable[Event]:
236+
for candidate in response.candidates:
237+
yield choice_event(
238+
finish_reason=_map_finish_reason(candidate.finish_reason),
239+
index=candidate.index,
240+
# default to "model" since Vertex uses that instead of assistant
241+
message=ChoiceMessage(
242+
role=candidate.content.role or _MODEL,
243+
content=_parts_to_any_value(
244+
capture_content=capture_content,
245+
parts=candidate.content.parts,
246+
),
247+
),
248+
)
249+
250+
199251
def _parts_to_any_value(
200252
*,
201253
capture_content: bool,
@@ -208,3 +260,22 @@ def _parts_to_any_value(
208260
cast("dict[str, AnyValue]", type(part).to_dict(part)) # type: ignore[reportUnknownMemberType]
209261
for part in parts
210262
]
263+
264+
265+
def _map_finish_reason(
266+
finish_reason: content.Candidate.FinishReason
267+
| content_v1beta1.Candidate.FinishReason,
268+
) -> FinishReason | str:
269+
EnumType = type(finish_reason) # pylint: disable=invalid-name
270+
if (
271+
finish_reason is EnumType.FINISH_REASON_UNSPECIFIED
272+
or finish_reason is EnumType.OTHER
273+
):
274+
return "error"
275+
if finish_reason is EnumType.STOP:
276+
return "stop"
277+
if finish_reason is EnumType.MAX_TOKENS:
278+
return "length"
279+
280+
# If there is no 1:1 mapping to an OTel preferred enum value, use the exact vertex reason
281+
return finish_reason.name

instrumentation-genai/opentelemetry-instrumentation-vertexai/tests/test_chat_completions.py

+73-17
Original file line numberDiff line numberDiff line change
@@ -38,28 +38,53 @@ def test_generate_content(
3838
assert dict(spans[0].attributes) == {
3939
"gen_ai.operation.name": "chat",
4040
"gen_ai.request.model": "gemini-1.5-flash-002",
41+
"gen_ai.response.finish_reasons": ("stop",),
42+
"gen_ai.response.model": "gemini-1.5-flash-002",
4143
"gen_ai.system": "vertex_ai",
44+
"gen_ai.usage.input_tokens": 5,
45+
"gen_ai.usage.output_tokens": 19,
4246
"server.address": "us-central1-aiplatform.googleapis.com",
4347
"server.port": 443,
4448
}
4549

46-
# Emits content event
50+
# Emits user and choice events
4751
logs = log_exporter.get_finished_logs()
48-
assert len(logs) == 1
49-
log_record = logs[0].log_record
52+
assert len(logs) == 2
53+
user_log, choice_log = [log_data.log_record for log_data in logs]
54+
5055
span_context = spans[0].get_span_context()
51-
assert log_record.trace_id == span_context.trace_id
52-
assert log_record.span_id == span_context.span_id
53-
assert log_record.trace_flags == span_context.trace_flags
54-
assert log_record.attributes == {
56+
assert user_log.trace_id == span_context.trace_id
57+
assert user_log.span_id == span_context.span_id
58+
assert user_log.trace_flags == span_context.trace_flags
59+
assert user_log.attributes == {
5560
"gen_ai.system": "vertex_ai",
5661
"event.name": "gen_ai.user.message",
5762
}
58-
assert log_record.body == {
63+
assert user_log.body == {
5964
"content": [{"text": "Say this is a test"}],
6065
"role": "user",
6166
}
6267

68+
assert choice_log.trace_id == span_context.trace_id
69+
assert choice_log.span_id == span_context.span_id
70+
assert choice_log.trace_flags == span_context.trace_flags
71+
assert choice_log.attributes == {
72+
"gen_ai.system": "vertex_ai",
73+
"event.name": "gen_ai.choice",
74+
}
75+
assert choice_log.body == {
76+
"finish_reason": "stop",
77+
"index": 0,
78+
"message": {
79+
"content": [
80+
{
81+
"text": "Okay, I understand. I'm ready for your test. Please proceed.\n"
82+
}
83+
],
84+
"role": "model",
85+
},
86+
}
87+
6388

6489
@pytest.mark.vcr
6590
def test_generate_content_without_events(
@@ -81,20 +106,34 @@ def test_generate_content_without_events(
81106
assert dict(spans[0].attributes) == {
82107
"gen_ai.operation.name": "chat",
83108
"gen_ai.request.model": "gemini-1.5-flash-002",
109+
"gen_ai.response.finish_reasons": ("stop",),
110+
"gen_ai.response.model": "gemini-1.5-flash-002",
84111
"gen_ai.system": "vertex_ai",
112+
"gen_ai.usage.input_tokens": 5,
113+
"gen_ai.usage.output_tokens": 19,
85114
"server.address": "us-central1-aiplatform.googleapis.com",
86115
"server.port": 443,
87116
}
88117

89-
# Emits event without body.content
118+
# Emits user and choice event without body.content
90119
logs = log_exporter.get_finished_logs()
91-
assert len(logs) == 1
92-
log_record = logs[0].log_record
93-
assert log_record.attributes == {
120+
assert len(logs) == 2
121+
user_log, choice_log = [log_data.log_record for log_data in logs]
122+
assert user_log.attributes == {
94123
"gen_ai.system": "vertex_ai",
95124
"event.name": "gen_ai.user.message",
96125
}
97-
assert log_record.body == {"role": "user"}
126+
assert user_log.body == {"role": "user"}
127+
128+
assert choice_log.attributes == {
129+
"gen_ai.system": "vertex_ai",
130+
"event.name": "gen_ai.choice",
131+
}
132+
assert choice_log.body == {
133+
"finish_reason": "stop",
134+
"index": 0,
135+
"message": {"role": "model"},
136+
}
98137

99138

100139
@pytest.mark.vcr
@@ -255,7 +294,11 @@ def test_generate_content_extra_params(span_exporter, instrument_no_content):
255294
"gen_ai.request.stop_sequences": ("\n\n\n",),
256295
"gen_ai.request.temperature": 0.20000000298023224,
257296
"gen_ai.request.top_p": 0.949999988079071,
297+
"gen_ai.response.finish_reasons": ("length",),
298+
"gen_ai.response.model": "gemini-1.5-flash-002",
258299
"gen_ai.system": "vertex_ai",
300+
"gen_ai.usage.input_tokens": 5,
301+
"gen_ai.usage.output_tokens": 5,
259302
"server.address": "us-central1-aiplatform.googleapis.com",
260303
"server.port": 443,
261304
}
@@ -274,7 +317,7 @@ def assert_span_error(span: ReadableSpan) -> None:
274317

275318

276319
@pytest.mark.vcr
277-
def test_generate_content_all_input_events(
320+
def test_generate_content_all_events(
278321
log_exporter: InMemoryLogExporter,
279322
instrument_with_content: VertexAIInstrumentor,
280323
):
@@ -299,10 +342,10 @@ def test_generate_content_all_input_events(
299342
],
300343
)
301344

302-
# Emits a system event, 2 users events, and a assistant event
345+
# Emits a system event, 2 users events, an assistant event, and the choice (response) event
303346
logs = log_exporter.get_finished_logs()
304-
assert len(logs) == 4
305-
system_log, user_log1, assistant_log, user_log2 = [
347+
assert len(logs) == 5
348+
system_log, user_log1, assistant_log, user_log2, choice_log = [
306349
log_data.log_record for log_data in logs
307350
]
308351

@@ -342,3 +385,16 @@ def test_generate_content_all_input_events(
342385
"content": [{"text": "Address me by name and say this is a test"}],
343386
"role": "user",
344387
}
388+
389+
assert choice_log.attributes == {
390+
"gen_ai.system": "vertex_ai",
391+
"event.name": "gen_ai.choice",
392+
}
393+
assert choice_log.body == {
394+
"finish_reason": "stop",
395+
"index": 0,
396+
"message": {
397+
"content": [{"text": "OpenTelemetry, this is a test.\n"}],
398+
"role": "model",
399+
},
400+
}

0 commit comments

Comments
 (0)