Skip to content

Commit ca06c60

Browse files
committed
Vertex response gen_ai.choice events
1 parent 7398657 commit ca06c60

File tree

5 files changed

+183
-25
lines changed

5 files changed

+183
-25
lines changed

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

Lines changed: 48 additions & 0 deletions
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

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
get_server_attributes,
3030
get_span_name,
3131
request_to_events,
32+
response_to_events,
3233
)
3334
from opentelemetry.trace import SpanKind, Tracer
3435

@@ -131,10 +132,11 @@ def traced_method(
131132

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

140142
return traced_method

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

Lines changed: 51 additions & 1 deletion
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
)
@@ -55,6 +58,9 @@
5558
)
5659

5760

61+
_MODEL = "model"
62+
63+
5864
@dataclass(frozen=True)
5965
class GenerateContentParams:
6066
model: str
@@ -204,7 +210,7 @@ def request_to_events(
204210

205211
for content in params.contents or []:
206212
# Assistant message
207-
if content.role == "model":
213+
if content.role == _MODEL:
208214
request_content = _parts_to_any_value(
209215
capture_content=capture_content, parts=content.parts
210216
)
@@ -218,6 +224,27 @@ def request_to_events(
218224
yield user_event(role=content.role, content=request_content)
219225

220226

227+
def response_to_events(
228+
*,
229+
response: prediction_service.GenerateContentResponse
230+
| prediction_service_v1beta1.GenerateContentResponse,
231+
capture_content: bool,
232+
) -> Iterable[Event]:
233+
for candidate in response.candidates:
234+
yield choice_event(
235+
finish_reason=_map_finish_reason(candidate.finish_reason),
236+
index=candidate.index,
237+
# default to "model" since Vertex uses that instead of assistant
238+
message=ChoiceMessage(
239+
role=candidate.content.role or _MODEL,
240+
content=_parts_to_any_value(
241+
capture_content=capture_content,
242+
parts=candidate.content.parts,
243+
),
244+
),
245+
)
246+
247+
221248
def _parts_to_any_value(
222249
*,
223250
capture_content: bool,
@@ -230,3 +257,26 @@ def _parts_to_any_value(
230257
cast("dict[str, AnyValue]", type(part).to_dict(part)) # type: ignore[reportUnknownMemberType]
231258
for part in parts
232259
]
260+
261+
262+
def _map_finish_reason(
263+
finish_reason: content.Candidate.FinishReason
264+
| content_v1beta1.Candidate.FinishReason,
265+
) -> FinishReason | str:
266+
EnumType = type(finish_reason) # pylint: disable=invalid-name
267+
if (
268+
finish_reason is EnumType.FINISH_REASON_UNSPECIFIED
269+
or finish_reason is EnumType.OTHER
270+
):
271+
return "error"
272+
if finish_reason is EnumType.STOP:
273+
return "stop"
274+
if finish_reason is EnumType.MAX_TOKENS:
275+
return "length"
276+
277+
# There are a lot of specific enum values from Vertex that would map to "content_filter".
278+
# I'm worried trying to map the enum obfuscates the telemetry because 1) it over
279+
# generalizes and 2) half of the values are from the OTel enum and others from the vertex
280+
# enum. See for reference
281+
# https://github.com/googleapis/python-aiplatform/blob/c5023698c7068e2f84523f91b824641c9ef2d694/google/cloud/aiplatform_v1/types/content.py#L786-L822
282+
return finish_reason.name.lower()
Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -73,9 +73,23 @@ interactions:
7373
"usageMetadata": {
7474
"promptTokenCount": 25,
7575
"candidatesTokenCount": 9,
76-
"totalTokenCount": 34
76+
"totalTokenCount": 34,
77+
"promptTokensDetails": [
78+
{
79+
"modality": 1,
80+
"tokenCount": 25
81+
}
82+
],
83+
"candidatesTokensDetails": [
84+
{
85+
"modality": 1,
86+
"tokenCount": 9
87+
}
88+
]
7789
},
78-
"modelVersion": "gemini-1.5-flash-002"
90+
"modelVersion": "gemini-1.5-flash-002",
91+
"createTime": "2025-02-03T23:33:06.046251Z",
92+
"responseId": "MlKhZ6vpAoifnvgPhceYyA4"
7993
}
8094
headers:
8195
Content-Type:
@@ -87,7 +101,7 @@ interactions:
87101
- X-Origin
88102
- Referer
89103
content-length:
90-
- '422'
104+
- '715'
91105
status:
92106
code: 200
93107
message: OK

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

Lines changed: 61 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -47,23 +47,44 @@ def test_generate_content(
4747
"server.port": 443,
4848
}
4949

50-
# Emits content event
50+
# Emits user and choice events
5151
logs = log_exporter.get_finished_logs()
52-
assert len(logs) == 1
53-
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+
5455
span_context = spans[0].get_span_context()
55-
assert log_record.trace_id == span_context.trace_id
56-
assert log_record.span_id == span_context.span_id
57-
assert log_record.trace_flags == span_context.trace_flags
58-
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 == {
5960
"gen_ai.system": "vertex_ai",
6061
"event.name": "gen_ai.user.message",
6162
}
62-
assert log_record.body == {
63+
assert user_log.body == {
6364
"content": [{"text": "Say this is a test"}],
6465
"role": "user",
6566
}
6667

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+
6788

6889
@pytest.mark.vcr
6990
def test_generate_content_without_events(
@@ -94,15 +115,25 @@ def test_generate_content_without_events(
94115
"server.port": 443,
95116
}
96117

97-
# Emits event without body.content
118+
# Emits user and choice event without body.content
98119
logs = log_exporter.get_finished_logs()
99-
assert len(logs) == 1
100-
log_record = logs[0].log_record
101-
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 == {
102123
"gen_ai.system": "vertex_ai",
103124
"event.name": "gen_ai.user.message",
104125
}
105-
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+
}
106137

107138

108139
@pytest.mark.vcr
@@ -286,7 +317,7 @@ def assert_span_error(span: ReadableSpan) -> None:
286317

287318

288319
@pytest.mark.vcr
289-
def test_generate_content_all_input_events(
320+
def test_generate_content_all_events(
290321
log_exporter: InMemoryLogExporter,
291322
instrument_with_content: VertexAIInstrumentor,
292323
):
@@ -311,10 +342,10 @@ def test_generate_content_all_input_events(
311342
],
312343
)
313344

314-
# 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
315346
logs = log_exporter.get_finished_logs()
316-
assert len(logs) == 4
317-
system_log, user_log1, assistant_log, user_log2 = [
347+
assert len(logs) == 5
348+
system_log, user_log1, assistant_log, user_log2, choice_log = [
318349
log_data.log_record for log_data in logs
319350
]
320351

@@ -354,3 +385,16 @@ def test_generate_content_all_input_events(
354385
"content": [{"text": "Address me by name and say this is a test"}],
355386
"role": "user",
356387
}
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)