Skip to content

Commit 28eed8f

Browse files
authored
Update vertexai instrumentation to be in-line with the latest semantic conventions part 2 (#3799)
* More changes * Update instrumentation to emit spans in the new format, call the upload hook * Update example and changelog * Fix linter
1 parent d368d68 commit 28eed8f

File tree

14 files changed

+450
-122
lines changed

14 files changed

+450
-122
lines changed

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## Unreleased
99

10-
- Start making changes to implement the big semantic convention changes made in https://github.com/open-telemetry/semantic-conventions/pull/2179.
11-
Now only a single event (`gen_ai.client.inference.operation.details`) is used to capture Chat History. These changes will be opt-in,
12-
users will need to set the environment variable OTEL_SEMCONV_STABILITY_OPT_IN to `gen_ai_latest_experimental` to see them ([#3386](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3386)).
10+
- Update instrumentation to use the latest semantic convention changes made in https://github.com/open-telemetry/semantic-conventions/pull/2179.
11+
Now only a single event and span (`gen_ai.client.inference.operation.details`) are used to capture prompt and response content. These changes are opt-in,
12+
users will need to set the environment variable OTEL_SEMCONV_STABILITY_OPT_IN to `gen_ai_latest_experimental` to see them ([#3799](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3799)) and ([#3709](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3709)).
1313
- Implement uninstrument for `opentelemetry-instrumentation-vertexai`
1414
([#3328](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3328))
1515
- VertexAI support for async calling

instrumentation-genai/opentelemetry-instrumentation-vertexai/examples/manual/.env

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,17 @@ OTEL_SERVICE_NAME=opentelemetry-python-vertexai
66

77
# Change to 'false' to hide prompt and completion content
88
OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=true
9+
10+
# Alternatively set this env var to enable the latest semantic conventions:
11+
OTEL_SEMCONV_STABILITY_OPT_IN=gen_ai_latest_experimental
12+
13+
# When using the latest experimental flag this env var controls which telemetry signals will have prompt and response content included in them.
14+
# Choices are NO_CONTENT, SPAN_ONLY, EVENT_ONLY, SPAN_AND_EVENT.
15+
OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=SPAN_AND_EVENT
16+
17+
# Optional hook that will upload prompt and response content to some external destination.
18+
# For example fsspec.
19+
OTEL_INSTRUMENTATION_GENAI_COMPLETION_HOOK = "upload"
20+
21+
# Required if using a completion hook. The path to upload content to for example gs://my_bucket.
22+
OTEL_INSTRUMENTATION_GENAI_UPLOAD_BASE_PATH = "gs://my_bucket"

instrumentation-genai/opentelemetry-instrumentation-vertexai/examples/zero-code/.env

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,17 @@ OTEL_PYTHON_LOGGING_AUTO_INSTRUMENTATION_ENABLED=true
1212

1313
# Change to 'false' to hide prompt and completion content
1414
OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=true
15+
16+
# Alternatively set this env var to enable the latest semantic conventions:
17+
OTEL_SEMCONV_STABILITY_OPT_IN=gen_ai_latest_experimental
18+
19+
# When using the latest experimental flag this env var controls which telemetry signals will have prompt and response content included in them.
20+
# Choices are NO_CONTENT, SPAN_ONLY, EVENT_ONLY, SPAN_AND_EVENT.
21+
OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=SPAN_AND_EVENT
22+
23+
# Optional hook that will upload prompt and response content to some external destination.
24+
# For example fsspec.
25+
OTEL_INSTRUMENTATION_GENAI_COMPLETION_HOOK = "upload"
26+
27+
# Required if using a completion hook. The path to upload content to for example gs://my_bucket.
28+
OTEL_INSTRUMENTATION_GENAI_UPLOAD_BASE_PATH = "gs://my_bucket"

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@
6060
from opentelemetry.instrumentation.vertexai.utils import is_content_enabled
6161
from opentelemetry.semconv.schemas import Schemas
6262
from opentelemetry.trace import get_tracer
63+
from opentelemetry.util.genai.completion_hook import load_completion_hook
6364

6465

6566
def _methods_to_wrap(
@@ -109,6 +110,9 @@ def instrumentation_dependencies(self) -> Collection[str]:
109110

110111
def _instrument(self, **kwargs: Any):
111112
"""Enable VertexAI instrumentation."""
113+
completion_hook = (
114+
kwargs.get("completion_hook") or load_completion_hook()
115+
)
112116
sem_conv_opt_in_mode = _OpenTelemetrySemanticConventionStability._get_opentelemetry_stability_opt_in_mode(
113117
_OpenTelemetryStabilitySignalType.GEN_AI,
114118
)
@@ -141,6 +145,7 @@ def _instrument(self, **kwargs: Any):
141145
event_logger,
142146
is_content_enabled(sem_conv_opt_in_mode),
143147
sem_conv_opt_in_mode,
148+
completion_hook,
144149
)
145150
elif sem_conv_opt_in_mode == _StabilityMode.GEN_AI_LATEST_EXPERIMENTAL:
146151
# Type checker now knows it's the other literal
@@ -149,6 +154,7 @@ def _instrument(self, **kwargs: Any):
149154
event_logger,
150155
is_content_enabled(sem_conv_opt_in_mode),
151156
sem_conv_opt_in_mode,
157+
completion_hook,
152158
)
153159
else:
154160
raise RuntimeError(f"{sem_conv_opt_in_mode} mode not supported")

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

Lines changed: 90 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from __future__ import annotations
1616

1717
from contextlib import contextmanager
18+
from dataclasses import asdict
1819
from typing import (
1920
TYPE_CHECKING,
2021
Any,
@@ -27,22 +28,32 @@
2728
overload,
2829
)
2930

30-
from opentelemetry._events import EventLogger
31+
from opentelemetry._events import Event, EventLogger
3132
from opentelemetry.instrumentation._semconv import (
3233
_StabilityMode,
3334
)
3435
from opentelemetry.instrumentation.vertexai.utils import (
3536
GenerateContentParams,
36-
create_operation_details_event,
37+
_map_finish_reason,
38+
convert_content_to_message_parts,
3739
get_genai_request_attributes,
3840
get_genai_response_attributes,
3941
get_server_attributes,
4042
get_span_name,
4143
request_to_events,
4244
response_to_events,
4345
)
46+
from opentelemetry.semconv._incubating.attributes import (
47+
gen_ai_attributes as GenAI,
48+
)
4449
from opentelemetry.trace import SpanKind, Tracer
45-
from opentelemetry.util.genai.types import ContentCapturingMode
50+
from opentelemetry.util.genai.completion_hook import CompletionHook
51+
from opentelemetry.util.genai.types import (
52+
ContentCapturingMode,
53+
InputMessage,
54+
OutputMessage,
55+
)
56+
from opentelemetry.util.genai.utils import gen_ai_json_dumps
4657

4758
if TYPE_CHECKING:
4859
from google.cloud.aiplatform_v1.services.prediction_service import client
@@ -110,6 +121,7 @@ def __init__(
110121
sem_conv_opt_in_mode: Literal[
111122
_StabilityMode.GEN_AI_LATEST_EXPERIMENTAL
112123
],
124+
completion_hook: CompletionHook,
113125
) -> None: ...
114126

115127
@overload
@@ -119,6 +131,7 @@ def __init__(
119131
event_logger: EventLogger,
120132
capture_content: bool,
121133
sem_conv_opt_in_mode: Literal[_StabilityMode.DEFAULT],
134+
completion_hook: CompletionHook,
122135
) -> None: ...
123136

124137
def __init__(
@@ -130,11 +143,13 @@ def __init__(
130143
Literal[_StabilityMode.DEFAULT],
131144
Literal[_StabilityMode.GEN_AI_LATEST_EXPERIMENTAL],
132145
],
146+
completion_hook: CompletionHook,
133147
) -> None:
134148
self.tracer = tracer
135149
self.event_logger = event_logger
136150
self.capture_content = capture_content
137151
self.sem_conv_opt_in_mode = sem_conv_opt_in_mode
152+
self.completion_hook = completion_hook
138153

139154
@contextmanager
140155
def _with_new_instrumentation(
@@ -146,40 +161,88 @@ def _with_new_instrumentation(
146161
kwargs: Any,
147162
):
148163
params = _extract_params(*args, **kwargs)
149-
api_endpoint: str = instance.api_endpoint # type: ignore[reportUnknownMemberType]
150-
span_attributes = {
151-
**get_genai_request_attributes(False, params),
152-
**get_server_attributes(api_endpoint),
153-
}
154-
155-
span_name = get_span_name(span_attributes)
156-
164+
request_attributes = get_genai_request_attributes(True, params)
157165
with self.tracer.start_as_current_span(
158-
name=span_name,
166+
name=f"{GenAI.GenAiOperationNameValues.CHAT.value} {request_attributes.get(GenAI.GEN_AI_REQUEST_MODEL, '')}".strip(),
159167
kind=SpanKind.CLIENT,
160-
attributes=span_attributes,
161168
) as span:
162169

163170
def handle_response(
164171
response: prediction_service.GenerateContentResponse
165172
| prediction_service_v1beta1.GenerateContentResponse
166173
| None,
167174
) -> None:
168-
if span.is_recording() and response:
169-
# When streaming, this is called multiple times so attributes would be
170-
# overwritten. In practice, it looks the API only returns the interesting
171-
# attributes on the last streamed response. However, I couldn't find
172-
# documentation for this and setting attributes shouldn't be too expensive.
173-
span.set_attributes(
174-
get_genai_response_attributes(response)
175-
)
176-
self.event_logger.emit(
177-
create_operation_details_event(
178-
api_endpoint=api_endpoint,
179-
params=params,
180-
capture_content=capture_content,
181-
response=response,
175+
attributes = (
176+
get_server_attributes(instance.api_endpoint) # type: ignore[reportUnknownMemberType]
177+
| request_attributes
178+
| get_genai_response_attributes(response)
179+
)
180+
system_instructions, inputs, outputs = [], [], []
181+
if params.system_instruction:
182+
system_instructions = convert_content_to_message_parts(
183+
params.system_instruction
182184
)
185+
if params.contents:
186+
inputs = [
187+
InputMessage(
188+
role=content.role,
189+
parts=convert_content_to_message_parts(content),
190+
)
191+
for content in params.contents
192+
]
193+
if response:
194+
outputs = [
195+
OutputMessage(
196+
finish_reason=_map_finish_reason(
197+
candidate.finish_reason
198+
),
199+
role=candidate.content.role,
200+
parts=convert_content_to_message_parts(
201+
candidate.content
202+
),
203+
)
204+
for candidate in response.candidates
205+
]
206+
content_attributes = {
207+
k: [asdict(x) for x in v]
208+
for k, v in [
209+
(
210+
GenAI.GEN_AI_SYSTEM_INSTRUCTIONS,
211+
system_instructions,
212+
),
213+
(GenAI.GEN_AI_INPUT_MESSAGES, inputs),
214+
(GenAI.GEN_AI_OUTPUT_MESSAGES, outputs),
215+
]
216+
if v
217+
}
218+
if span.is_recording():
219+
span.set_attributes(attributes)
220+
if capture_content in (
221+
ContentCapturingMode.SPAN_AND_EVENT,
222+
ContentCapturingMode.SPAN_ONLY,
223+
):
224+
span.set_attributes(
225+
{
226+
k: gen_ai_json_dumps(v)
227+
for k, v in content_attributes.items()
228+
}
229+
)
230+
event = Event(
231+
name="gen_ai.client.inference.operation.details",
232+
)
233+
event.attributes = attributes
234+
if capture_content in (
235+
ContentCapturingMode.SPAN_AND_EVENT,
236+
ContentCapturingMode.EVENT_ONLY,
237+
):
238+
event.attributes |= content_attributes
239+
self.event_logger.emit(event)
240+
self.completion_hook.on_completion(
241+
inputs=inputs,
242+
outputs=outputs,
243+
system_instruction=system_instructions,
244+
span=span,
245+
log_record=event,
183246
)
184247

185248
yield handle_response

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

Lines changed: 8 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
from __future__ import annotations
1818

1919
import re
20-
from dataclasses import asdict, dataclass
20+
from dataclasses import dataclass
2121
from os import environ
2222
from typing import (
2323
TYPE_CHECKING,
@@ -53,9 +53,7 @@
5353
from opentelemetry.util.genai.types import (
5454
ContentCapturingMode,
5555
FinishReason,
56-
InputMessage,
5756
MessagePart,
58-
OutputMessage,
5957
Text,
6058
ToolCall,
6159
ToolCallResponse,
@@ -192,8 +190,11 @@ def get_genai_request_attributes( # pylint: disable=too-many-branches
192190

193191
def get_genai_response_attributes(
194192
response: prediction_service.GenerateContentResponse
195-
| prediction_service_v1beta1.GenerateContentResponse,
193+
| prediction_service_v1beta1.GenerateContentResponse
194+
| None,
196195
) -> dict[str, AttributeValue]:
196+
if not response:
197+
return {}
197198
finish_reasons: list[str] = [
198199
_map_finish_reason(candidate.finish_reason)
199200
for candidate in response.candidates
@@ -307,68 +308,9 @@ def request_to_events(
307308
yield user_event(role=content.role, content=request_content)
308309

309310

310-
def create_operation_details_event(
311-
*,
312-
api_endpoint: str,
313-
response: prediction_service.GenerateContentResponse
314-
| prediction_service_v1beta1.GenerateContentResponse
315-
| None,
316-
params: GenerateContentParams,
317-
capture_content: ContentCapturingMode,
318-
) -> Event:
319-
event = Event(name="gen_ai.client.inference.operation.details")
320-
attributes: dict[str, AnyValue] = {
321-
**get_genai_request_attributes(True, params),
322-
**get_server_attributes(api_endpoint),
323-
**(get_genai_response_attributes(response) if response else {}),
324-
}
325-
event.attributes = attributes
326-
if capture_content in {
327-
ContentCapturingMode.NO_CONTENT,
328-
ContentCapturingMode.SPAN_ONLY,
329-
}:
330-
return event
331-
if params.system_instruction:
332-
attributes[GenAIAttributes.GEN_AI_SYSTEM_INSTRUCTIONS] = [
333-
{
334-
"type": "text",
335-
"content": "\n".join(
336-
part.text for part in params.system_instruction.parts
337-
),
338-
}
339-
]
340-
if params.contents:
341-
attributes[GenAIAttributes.GEN_AI_INPUT_MESSAGES] = [
342-
asdict(_convert_content_to_message(content))
343-
for content in params.contents
344-
]
345-
if response and response.candidates:
346-
attributes[GenAIAttributes.GEN_AI_OUTPUT_MESSAGES] = [
347-
asdict(x) for x in _convert_response_to_output_messages(response)
348-
]
349-
return event
350-
351-
352-
def _convert_response_to_output_messages(
353-
response: prediction_service.GenerateContentResponse
354-
| prediction_service_v1beta1.GenerateContentResponse,
355-
) -> list[OutputMessage]:
356-
output_messages: list[OutputMessage] = []
357-
for candidate in response.candidates:
358-
message = _convert_content_to_message(candidate.content)
359-
output_messages.append(
360-
OutputMessage(
361-
finish_reason=_map_finish_reason(candidate.finish_reason),
362-
role=message.role,
363-
parts=message.parts,
364-
)
365-
)
366-
return output_messages
367-
368-
369-
def _convert_content_to_message(
311+
def convert_content_to_message_parts(
370312
content: content.Content | content_v1beta1.Content,
371-
) -> InputMessage:
313+
) -> list[MessagePart]:
372314
parts: MessagePart = []
373315
for idx, part in enumerate(content.parts):
374316
if "function_response" in part:
@@ -398,7 +340,7 @@ def _convert_content_to_message(
398340
)
399341
dict_part["type"] = type(part)
400342
parts.append(dict_part)
401-
return InputMessage(role=content.role, parts=parts)
343+
return parts
402344

403345

404346
def response_to_events(

0 commit comments

Comments
 (0)