Skip to content

Commit 17b7a15

Browse files
committed
VertexAI emit user events
1 parent ec3c51d commit 17b7a15

File tree

6 files changed

+293
-8
lines changed

6 files changed

+293
-8
lines changed
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
# Copyright The OpenTelemetry Authors
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+
"""
16+
Factories for event types described in
17+
https://github.com/open-telemetry/semantic-conventions/blob/main/docs/gen-ai/gen-ai-events.md#system-event.
18+
19+
Hopefully this code can be autogenerated by Weaver once Gen AI semantic conventions are
20+
schematized in YAML and the Weaver tool supports it.
21+
"""
22+
23+
from opentelemetry._events import Event
24+
from opentelemetry.semconv._incubating.attributes import gen_ai_attributes
25+
from opentelemetry.util.types import AnyValue
26+
27+
28+
def user_event(
29+
*,
30+
role: str = "user",
31+
content: AnyValue = None,
32+
) -> Event:
33+
"""Creates a User event
34+
https://github.com/open-telemetry/semantic-conventions/blob/v1.28.0/docs/gen-ai/gen-ai-events.md#user-event
35+
"""
36+
body: dict[str, AnyValue] = {
37+
"role": role,
38+
}
39+
if content is not None:
40+
body["content"] = content
41+
return Event(
42+
name="gen_ai.user.message",
43+
attributes={
44+
gen_ai_attributes.GEN_AI_SYSTEM: gen_ai_attributes.GenAiSystemValues.VERTEX_AI.value,
45+
},
46+
body=body,
47+
)

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

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
GenerateContentParams,
2727
get_genai_request_attributes,
2828
get_span_name,
29+
request_to_events,
2930
)
3031
from opentelemetry.trace import SpanKind, Tracer
3132

@@ -107,13 +108,12 @@ def traced_method(
107108
name=span_name,
108109
kind=SpanKind.CLIENT,
109110
attributes=span_attributes,
110-
) as _span:
111-
# TODO: emit request events
112-
# if span.is_recording():
113-
# for message in kwargs.get("messages", []):
114-
# event_logger.emit(
115-
# message_to_event(message, capture_content)
116-
# )
111+
) as span:
112+
if span.is_recording():
113+
for event in request_to_events(
114+
params=params, capture_content=capture_content
115+
):
116+
event_logger.emit(event)
117117

118118
# TODO: set error.type attribute
119119
# https://github.com/open-telemetry/semantic-conventions/blob/main/docs/gen-ai/gen-ai-spans.md

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

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,17 +16,22 @@
1616

1717
import re
1818
from dataclasses import dataclass
19+
from enum import Enum
1920
from os import environ
2021
from typing import (
2122
TYPE_CHECKING,
23+
Iterable,
2224
Mapping,
2325
Sequence,
26+
cast,
2427
)
2528

29+
from opentelemetry._events import Event
30+
from opentelemetry.instrumentation.vertexai.events import user_event
2631
from opentelemetry.semconv._incubating.attributes import (
2732
gen_ai_attributes as GenAIAttributes,
2833
)
29-
from opentelemetry.util.types import AttributeValue
34+
from opentelemetry.util.types import AnyValue, AttributeValue
3035

3136
if TYPE_CHECKING:
3237
from google.cloud.aiplatform_v1.types import content, tool
@@ -137,3 +142,21 @@ def get_span_name(span_attributes: Mapping[str, AttributeValue]) -> str:
137142
if not model:
138143
return f"{name}"
139144
return f"{name} {model}"
145+
146+
147+
def request_to_events(
148+
*, params: GenerateContentParams, capture_content: bool
149+
) -> Iterable[Event]:
150+
for content in params.contents or []:
151+
if content.role == "model":
152+
# TODO: handle assistant message
153+
pass
154+
# Assume user event but role should be "user"
155+
else:
156+
request_content = None
157+
if capture_content:
158+
request_content = [
159+
cast(dict[str, AnyValue], type(part).to_dict(part)) # type: ignore[reportUnknownMemberType]
160+
for part in content.parts
161+
]
162+
yield user_event(role=content.role, content=request_content)
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
interactions:
2+
- request:
3+
body: |-
4+
{
5+
"contents": [
6+
{
7+
"role": "invalid_role",
8+
"parts": [
9+
{
10+
"text": "Say this is a test"
11+
}
12+
]
13+
}
14+
]
15+
}
16+
headers:
17+
Accept:
18+
- '*/*'
19+
Accept-Encoding:
20+
- gzip, deflate
21+
Connection:
22+
- keep-alive
23+
Content-Length:
24+
- '149'
25+
Content-Type:
26+
- application/json
27+
User-Agent:
28+
- python-requests/2.32.3
29+
method: POST
30+
uri: https://us-central1-aiplatform.googleapis.com/v1/projects/fake-project/locations/us-central1/publishers/google/models/gemini-1.5-flash-002:generateContent?%24alt=json%3Benum-encoding%3Dint
31+
response:
32+
body:
33+
string: |-
34+
{
35+
"error": {
36+
"code": 400,
37+
"message": "Please use a valid role: user, model.",
38+
"status": "INVALID_ARGUMENT",
39+
"details": []
40+
}
41+
}
42+
headers:
43+
Content-Type:
44+
- application/json; charset=UTF-8
45+
Transfer-Encoding:
46+
- chunked
47+
Vary:
48+
- Origin
49+
- X-Origin
50+
- Referer
51+
content-length:
52+
- '416'
53+
status:
54+
code: 400
55+
message: Bad Request
56+
version: 1
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
interactions:
2+
- request:
3+
body: |-
4+
{
5+
"contents": [
6+
{
7+
"role": "user",
8+
"parts": [
9+
{
10+
"text": "Say this is a test"
11+
}
12+
]
13+
}
14+
]
15+
}
16+
headers:
17+
Accept:
18+
- '*/*'
19+
Accept-Encoding:
20+
- gzip, deflate
21+
Connection:
22+
- keep-alive
23+
Content-Length:
24+
- '141'
25+
Content-Type:
26+
- application/json
27+
User-Agent:
28+
- python-requests/2.32.3
29+
method: POST
30+
uri: https://us-central1-aiplatform.googleapis.com/v1/projects/fake-project/locations/us-central1/publishers/google/models/gemini-1.5-flash-002:generateContent?%24alt=json%3Benum-encoding%3Dint
31+
response:
32+
body:
33+
string: |-
34+
{
35+
"candidates": [
36+
{
37+
"content": {
38+
"role": "model",
39+
"parts": [
40+
{
41+
"text": "Okay, I understand. I'm ready for your test. Please proceed.\n"
42+
}
43+
]
44+
},
45+
"finishReason": 1,
46+
"avgLogprobs": -0.005519990466142956
47+
}
48+
],
49+
"usageMetadata": {
50+
"promptTokenCount": 5,
51+
"candidatesTokenCount": 19,
52+
"totalTokenCount": 24
53+
},
54+
"modelVersion": "gemini-1.5-flash-002"
55+
}
56+
headers:
57+
Content-Type:
58+
- application/json; charset=UTF-8
59+
Transfer-Encoding:
60+
- chunked
61+
Vary:
62+
- Origin
63+
- X-Origin
64+
- Referer
65+
content-length:
66+
- '453'
67+
status:
68+
code: 200
69+
message: OK
70+
version: 1

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

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@
88
)
99

1010
from opentelemetry.instrumentation.vertexai import VertexAIInstrumentor
11+
from opentelemetry.sdk._logs._internal.export.in_memory_log_exporter import (
12+
InMemoryLogExporter,
13+
)
1114
from opentelemetry.sdk.trace import ReadableSpan
1215
from opentelemetry.sdk.trace.export.in_memory_span_exporter import (
1316
InMemorySpanExporter,
@@ -18,6 +21,7 @@
1821
@pytest.mark.vcr
1922
def test_generate_content(
2023
span_exporter: InMemorySpanExporter,
24+
log_exporter: InMemoryLogExporter,
2125
instrument_with_content: VertexAIInstrumentor,
2226
):
2327
model = GenerativeModel("gemini-1.5-flash-002")
@@ -27,6 +31,48 @@ def test_generate_content(
2731
]
2832
)
2933

34+
# Emits span
35+
spans = span_exporter.get_finished_spans()
36+
assert len(spans) == 1
37+
assert spans[0].name == "chat gemini-1.5-flash-002"
38+
assert dict(spans[0].attributes) == {
39+
"gen_ai.operation.name": "chat",
40+
"gen_ai.request.model": "gemini-1.5-flash-002",
41+
"gen_ai.system": "vertex_ai",
42+
}
43+
44+
# Emits content event
45+
logs = log_exporter.get_finished_logs()
46+
assert len(logs) == 1
47+
log_record = logs[0].log_record
48+
span_context = spans[0].get_span_context()
49+
assert log_record.trace_id == span_context.trace_id
50+
assert log_record.span_id == span_context.span_id
51+
assert log_record.trace_flags == span_context.trace_flags
52+
assert log_record.attributes == {
53+
"gen_ai.system": "vertex_ai",
54+
"event.name": "gen_ai.user.message",
55+
}
56+
assert log_record.body == {
57+
"content": [{"text": "Say this is a test"}],
58+
"role": "user",
59+
}
60+
61+
62+
@pytest.mark.vcr
63+
def test_generate_content_without_events(
64+
span_exporter: InMemorySpanExporter,
65+
log_exporter: InMemoryLogExporter,
66+
instrument_no_content: VertexAIInstrumentor,
67+
):
68+
model = GenerativeModel("gemini-1.5-flash-002")
69+
model.generate_content(
70+
[
71+
Content(role="user", parts=[Part.from_text("Say this is a test")]),
72+
]
73+
)
74+
75+
# Emits span
3076
spans = span_exporter.get_finished_spans()
3177
assert len(spans) == 1
3278
assert spans[0].name == "chat gemini-1.5-flash-002"
@@ -36,6 +82,16 @@ def test_generate_content(
3682
"gen_ai.system": "vertex_ai",
3783
}
3884

85+
# Emits event without body.content
86+
logs = log_exporter.get_finished_logs()
87+
assert len(logs) == 1
88+
log_record = logs[0].log_record
89+
assert log_record.attributes == {
90+
"gen_ai.system": "vertex_ai",
91+
"event.name": "gen_ai.user.message",
92+
}
93+
assert log_record.body == {"role": "user"}
94+
3995

4096
@pytest.mark.vcr
4197
def test_generate_content_empty_model(
@@ -98,6 +154,7 @@ def test_generate_content_missing_model(
98154
@pytest.mark.vcr
99155
def test_generate_content_invalid_temperature(
100156
span_exporter: InMemorySpanExporter,
157+
log_exporter: InMemoryLogExporter,
101158
instrument_with_content: VertexAIInstrumentor,
102159
):
103160
model = GenerativeModel("gemini-1.5-flash-002")
@@ -126,6 +183,38 @@ def test_generate_content_invalid_temperature(
126183
assert_span_error(spans[0])
127184

128185

186+
@pytest.mark.vcr
187+
def test_generate_content_invalid_role(
188+
log_exporter: InMemoryLogExporter,
189+
instrument_with_content: VertexAIInstrumentor,
190+
):
191+
model = GenerativeModel("gemini-1.5-flash-002")
192+
try:
193+
# Fails because role must be "user" or "model"
194+
model.generate_content(
195+
[
196+
Content(
197+
role="invalid_role",
198+
parts=[Part.from_text("Say this is a test")],
199+
)
200+
]
201+
)
202+
except BadRequest:
203+
pass
204+
205+
# Emits the faulty content which caused the request to fail
206+
logs = log_exporter.get_finished_logs()
207+
assert len(logs) == 1
208+
assert logs[0].log_record.attributes == {
209+
"gen_ai.system": "vertex_ai",
210+
"event.name": "gen_ai.user.message",
211+
}
212+
assert logs[0].log_record.body == {
213+
"content": [{"text": "Say this is a test"}],
214+
"role": "invalid_role",
215+
}
216+
217+
129218
@pytest.mark.vcr()
130219
def test_generate_content_extra_params(span_exporter, instrument_no_content):
131220
generation_config = GenerationConfig(

0 commit comments

Comments
 (0)