Skip to content

Commit a158d63

Browse files
etserendaabmass
andauthored
Genai utils | Add AgentInvocation type (#4274)
* GenAI Utils | Agent Base Type and Creation Span * fix lint and add _BaseAgent to sphinx nitpick exceptions * align AgentInvocation with invoke_agent semconv * update span utils * apply _lifecycle_context and error handling * apply general invoke methods and resolved merge conflict * clean up * fix(genai-utils): address review comments on AgentInvocation * fix(genai-utils): align AgentInvocation with invoke_agent semconv spec * chore: remove utils-demo app from PR * fix(genai-utils): address review comments on AgentInvocation * fix(genai-utils): move get_content_attributes to _invocation.py, add test coverage * feat(genai-utils): add finish_reasons to AgentInvocation per semconv * fix: replace getattr with string literals, add TODO for semconv migration, fix pylint * fix: simplify CHANGELOG entry per review --------- Co-authored-by: Aaron Abbott <aaronabbott@google.com>
1 parent a3ddd4f commit a158d63

9 files changed

Lines changed: 967 additions & 54 deletions

File tree

util/opentelemetry-util-genai/CHANGELOG.md

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

88
## Unreleased
99

10+
- Add `AgentInvocation` type with `invoke_agent` span lifecycle
11+
([#4274](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/4274))
1012
- Add metrics support for EmbeddingInvocation
1113
([#4377](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/4377))
1214
- Add support for workflow in genAI utils handler.

util/opentelemetry-util-genai/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ classifiers = [
2727
dependencies = [
2828
"opentelemetry-instrumentation ~= 0.60b0",
2929
"opentelemetry-semantic-conventions ~= 0.60b0",
30-
"opentelemetry-api>=1.39",
30+
"opentelemetry-api ~= 1.39",
3131
]
3232

3333
[project.entry-points.opentelemetry_genai_completion_hook]
Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
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+
from __future__ import annotations
16+
17+
from typing import Any
18+
19+
from opentelemetry._logs import Logger
20+
from opentelemetry.semconv._incubating.attributes import (
21+
gen_ai_attributes as GenAI,
22+
)
23+
from opentelemetry.semconv.attributes import server_attributes
24+
from opentelemetry.trace import SpanKind, Tracer
25+
from opentelemetry.util.genai._invocation import (
26+
Error,
27+
GenAIInvocation,
28+
get_content_attributes,
29+
)
30+
from opentelemetry.util.genai.metrics import InvocationMetricsRecorder
31+
from opentelemetry.util.genai.types import (
32+
InputMessage,
33+
MessagePart,
34+
OutputMessage,
35+
ToolDefinition,
36+
)
37+
38+
# TODO: Migrate to GenAI constants once available in semconv package
39+
_GEN_AI_AGENT_VERSION = "gen_ai.agent.version"
40+
_GEN_AI_USAGE_CACHE_CREATION_INPUT_TOKENS = (
41+
"gen_ai.usage.cache_creation.input_tokens"
42+
)
43+
_GEN_AI_USAGE_CACHE_READ_INPUT_TOKENS = "gen_ai.usage.cache_read.input_tokens"
44+
45+
46+
class AgentInvocation(GenAIInvocation):
47+
"""Represents a single agent invocation (invoke_agent span).
48+
49+
Use handler.start_invoke_local_agent() / handler.start_invoke_remote_agent()
50+
or the handler.invoke_local_agent() / handler.invoke_remote_agent() context
51+
managers rather than constructing this directly.
52+
53+
Reference:
54+
Client span: https://github.com/open-telemetry/semantic-conventions/blob/main/docs/gen-ai/gen-ai-agent-spans.md#invoke-agent-client-span
55+
Internal span: https://github.com/open-telemetry/semantic-conventions/blob/main/docs/gen-ai/gen-ai-agent-spans.md#invoke-agent-internal-span
56+
"""
57+
58+
def __init__(
59+
self,
60+
tracer: Tracer,
61+
metrics_recorder: InvocationMetricsRecorder,
62+
logger: Logger,
63+
provider: str,
64+
*,
65+
span_kind: SpanKind = SpanKind.INTERNAL,
66+
request_model: str | None = None,
67+
server_address: str | None = None,
68+
server_port: int | None = None,
69+
attributes: dict[str, Any] | None = None,
70+
metric_attributes: dict[str, Any] | None = None,
71+
) -> None:
72+
"""Use handler.start_invoke_local_agent() or handler.start_invoke_remote_agent() instead of calling this directly."""
73+
_operation_name = GenAI.GenAiOperationNameValues.INVOKE_AGENT.value
74+
super().__init__(
75+
tracer,
76+
metrics_recorder,
77+
logger,
78+
operation_name=_operation_name,
79+
span_name=_operation_name,
80+
span_kind=span_kind,
81+
attributes=attributes,
82+
metric_attributes=metric_attributes,
83+
)
84+
self.provider = provider
85+
self.request_model = request_model
86+
self.server_address = server_address
87+
self.server_port = server_port
88+
89+
self.agent_name: str | None = None
90+
self.agent_id: str | None = None
91+
self.agent_description: str | None = None
92+
self.agent_version: str | None = None
93+
94+
self.conversation_id: str | None = None
95+
self.data_source_id: str | None = None
96+
self.output_type: str | None = None
97+
98+
self.temperature: float | None = None
99+
self.top_p: float | None = None
100+
self.frequency_penalty: float | None = None
101+
self.presence_penalty: float | None = None
102+
self.max_tokens: int | None = None
103+
self.stop_sequences: list[str] | None = None
104+
self.seed: int | None = None
105+
self.choice_count: int | None = None
106+
107+
self.finish_reasons: list[str] | None = None
108+
109+
self.input_tokens: int | None = None
110+
self.output_tokens: int | None = None
111+
self.cache_creation_input_tokens: int | None = None
112+
self.cache_read_input_tokens: int | None = None
113+
114+
self.input_messages: list[InputMessage] = []
115+
self.output_messages: list[OutputMessage] = []
116+
self.system_instruction: list[MessagePart] = []
117+
self.tool_definitions: list[ToolDefinition] | None = None
118+
119+
self._start()
120+
121+
def _get_common_attributes(self) -> dict[str, Any]:
122+
optional_attrs = (
123+
(GenAI.GEN_AI_REQUEST_MODEL, self.request_model),
124+
(server_attributes.SERVER_ADDRESS, self.server_address),
125+
(server_attributes.SERVER_PORT, self.server_port),
126+
(GenAI.GEN_AI_AGENT_NAME, self.agent_name),
127+
(GenAI.GEN_AI_AGENT_ID, self.agent_id),
128+
(GenAI.GEN_AI_AGENT_DESCRIPTION, self.agent_description),
129+
(_GEN_AI_AGENT_VERSION, self.agent_version),
130+
)
131+
return {
132+
GenAI.GEN_AI_OPERATION_NAME: self._operation_name,
133+
GenAI.GEN_AI_PROVIDER_NAME: self.provider,
134+
**{k: v for k, v in optional_attrs if v is not None},
135+
}
136+
137+
def _get_request_attributes(self) -> dict[str, Any]:
138+
optional_attrs = (
139+
(GenAI.GEN_AI_CONVERSATION_ID, self.conversation_id),
140+
(GenAI.GEN_AI_DATA_SOURCE_ID, self.data_source_id),
141+
(GenAI.GEN_AI_OUTPUT_TYPE, self.output_type),
142+
(GenAI.GEN_AI_REQUEST_TEMPERATURE, self.temperature),
143+
(GenAI.GEN_AI_REQUEST_TOP_P, self.top_p),
144+
(GenAI.GEN_AI_REQUEST_FREQUENCY_PENALTY, self.frequency_penalty),
145+
(GenAI.GEN_AI_REQUEST_PRESENCE_PENALTY, self.presence_penalty),
146+
(GenAI.GEN_AI_REQUEST_MAX_TOKENS, self.max_tokens),
147+
(GenAI.GEN_AI_REQUEST_STOP_SEQUENCES, self.stop_sequences),
148+
(GenAI.GEN_AI_REQUEST_SEED, self.seed),
149+
(GenAI.GEN_AI_REQUEST_CHOICE_COUNT, self.choice_count),
150+
)
151+
return {k: v for k, v in optional_attrs if v is not None}
152+
153+
def _get_response_attributes(self) -> dict[str, Any]:
154+
if self.finish_reasons:
155+
return {GenAI.GEN_AI_RESPONSE_FINISH_REASONS: self.finish_reasons}
156+
return {}
157+
158+
def _get_usage_attributes(self) -> dict[str, Any]:
159+
optional_attrs = (
160+
(GenAI.GEN_AI_USAGE_INPUT_TOKENS, self.input_tokens),
161+
(GenAI.GEN_AI_USAGE_OUTPUT_TOKENS, self.output_tokens),
162+
(
163+
_GEN_AI_USAGE_CACHE_CREATION_INPUT_TOKENS,
164+
self.cache_creation_input_tokens,
165+
),
166+
(
167+
_GEN_AI_USAGE_CACHE_READ_INPUT_TOKENS,
168+
self.cache_read_input_tokens,
169+
),
170+
)
171+
return {k: v for k, v in optional_attrs if v is not None}
172+
173+
def _get_content_attributes_for_span(self) -> dict[str, Any]:
174+
return get_content_attributes(
175+
input_messages=self.input_messages,
176+
output_messages=self.output_messages,
177+
system_instruction=self.system_instruction,
178+
tool_definitions=self.tool_definitions,
179+
for_span=True,
180+
)
181+
182+
def _get_metric_attributes(self) -> dict[str, Any]:
183+
optional_attrs = (
184+
(GenAI.GEN_AI_PROVIDER_NAME, self.provider),
185+
(GenAI.GEN_AI_REQUEST_MODEL, self.request_model),
186+
(server_attributes.SERVER_ADDRESS, self.server_address),
187+
(server_attributes.SERVER_PORT, self.server_port),
188+
)
189+
attrs: dict[str, Any] = {
190+
GenAI.GEN_AI_OPERATION_NAME: self._operation_name,
191+
**{k: v for k, v in optional_attrs if v is not None},
192+
}
193+
attrs.update(self.metric_attributes)
194+
return attrs
195+
196+
def _get_metric_token_counts(self) -> dict[str, int]:
197+
counts: dict[str, int] = {}
198+
if self.input_tokens is not None:
199+
counts[GenAI.GenAiTokenTypeValues.INPUT.value] = self.input_tokens
200+
if self.output_tokens is not None:
201+
counts[GenAI.GenAiTokenTypeValues.OUTPUT.value] = (
202+
self.output_tokens
203+
)
204+
return counts
205+
206+
def _apply_finish(self, error: Error | None = None) -> None:
207+
if error is not None:
208+
self._apply_error_attributes(error)
209+
210+
# Update span name if agent_name was set after construction
211+
if self.agent_name:
212+
self.span.update_name(f"{self._operation_name} {self.agent_name}")
213+
214+
attributes: dict[str, Any] = {}
215+
attributes.update(self._get_common_attributes())
216+
attributes.update(self._get_request_attributes())
217+
attributes.update(self._get_response_attributes())
218+
attributes.update(self._get_usage_attributes())
219+
attributes.update(self._get_content_attributes_for_span())
220+
attributes.update(self.attributes)
221+
self.span.set_attributes(attributes)
222+
self._metrics_recorder.record(self)

util/opentelemetry-util-genai/src/opentelemetry/util/genai/_inference_invocation.py

Lines changed: 30 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414

1515
from __future__ import annotations
1616

17-
from dataclasses import asdict, dataclass, field
17+
from dataclasses import dataclass, field
1818
from typing import Any
1919

2020
from opentelemetry._logs import Logger, LogRecord
@@ -23,21 +23,29 @@
2323
)
2424
from opentelemetry.semconv.attributes import server_attributes
2525
from opentelemetry.trace import INVALID_SPAN, Span, SpanKind, Tracer
26-
from opentelemetry.util.genai._invocation import Error, GenAIInvocation
26+
from opentelemetry.util.genai._invocation import (
27+
Error,
28+
GenAIInvocation,
29+
get_content_attributes,
30+
)
2731
from opentelemetry.util.genai.metrics import InvocationMetricsRecorder
2832
from opentelemetry.util.genai.types import (
2933
InputMessage,
3034
MessagePart,
3135
OutputMessage,
36+
ToolDefinition,
3237
)
3338
from opentelemetry.util.genai.utils import (
34-
ContentCapturingMode,
35-
gen_ai_json_dumps,
36-
get_content_capturing_mode,
3739
is_experimental_mode,
3840
should_emit_event,
3941
)
4042

43+
# TODO: Migrate to GenAI constants once available in semconv package
44+
_GEN_AI_USAGE_CACHE_CREATION_INPUT_TOKENS = (
45+
"gen_ai.usage.cache_creation.input_tokens"
46+
)
47+
_GEN_AI_USAGE_CACHE_READ_INPUT_TOKENS = "gen_ai.usage.cache_read.input_tokens"
48+
4149

4250
class InferenceInvocation(GenAIInvocation):
4351
"""Represents a single LLM chat/completion call.
@@ -113,53 +121,19 @@ def __init__( # pylint: disable=too-many-locals
113121
self.seed = seed
114122
self.server_address = server_address
115123
self.server_port = server_port
124+
self.cache_creation_input_tokens: int | None = None
125+
self.cache_read_input_tokens: int | None = None
126+
self.tool_definitions: list[ToolDefinition] | None = None
116127
self._start()
117128

118129
def _get_message_attributes(self, *, for_span: bool) -> dict[str, Any]:
119-
if not is_experimental_mode():
120-
return {}
121-
mode = get_content_capturing_mode()
122-
allowed_modes = (
123-
(
124-
ContentCapturingMode.SPAN_ONLY,
125-
ContentCapturingMode.SPAN_AND_EVENT,
126-
)
127-
if for_span
128-
else (
129-
ContentCapturingMode.EVENT_ONLY,
130-
ContentCapturingMode.SPAN_AND_EVENT,
131-
)
132-
)
133-
if mode not in allowed_modes:
134-
return {}
135-
136-
def serialize(items: list[Any]) -> Any:
137-
dicts = [asdict(item) for item in items]
138-
return gen_ai_json_dumps(dicts) if for_span else dicts
139-
140-
optional_attrs = (
141-
(
142-
GenAI.GEN_AI_INPUT_MESSAGES,
143-
serialize(self.input_messages)
144-
if self.input_messages
145-
else None,
146-
),
147-
(
148-
GenAI.GEN_AI_OUTPUT_MESSAGES,
149-
serialize(self.output_messages)
150-
if self.output_messages
151-
else None,
152-
),
153-
(
154-
GenAI.GEN_AI_SYSTEM_INSTRUCTIONS,
155-
serialize(self.system_instruction)
156-
if self.system_instruction
157-
else None,
158-
),
130+
return get_content_attributes(
131+
input_messages=self.input_messages,
132+
output_messages=self.output_messages,
133+
system_instruction=self.system_instruction,
134+
tool_definitions=self.tool_definitions,
135+
for_span=for_span,
159136
)
160-
return {
161-
key: value for key, value in optional_attrs if value is not None
162-
}
163137

164138
def _get_finish_reasons(self) -> list[str] | None:
165139
if self.finish_reasons is not None:
@@ -200,6 +174,14 @@ def _get_attributes(self) -> dict[str, Any]:
200174
(GenAI.GEN_AI_RESPONSE_ID, self.response_id),
201175
(GenAI.GEN_AI_USAGE_INPUT_TOKENS, self.input_tokens),
202176
(GenAI.GEN_AI_USAGE_OUTPUT_TOKENS, self.output_tokens),
177+
(
178+
_GEN_AI_USAGE_CACHE_CREATION_INPUT_TOKENS,
179+
self.cache_creation_input_tokens,
180+
),
181+
(
182+
_GEN_AI_USAGE_CACHE_READ_INPUT_TOKENS,
183+
self.cache_read_input_tokens,
184+
),
203185
)
204186
attrs.update({k: v for k, v in optional_attrs if v is not None})
205187
return attrs

0 commit comments

Comments
 (0)