Skip to content

Commit 96a9d0f

Browse files
authored
GenAI Utils | Add more SemConv Attributes (#3862)
* feature: add genai llm request semconv attrs * feat: add response attributes to LLM invocation spans * fix inconsistent attrs on fail/stop * add changelog * fix: update version for semconvs * fix: clean up nits * refactor: redo type hints to use built-in generic types, remove optional typing * feat: deduplicate finish reasons in LLMInvocation attributes
1 parent 1d97282 commit 96a9d0f

File tree

6 files changed

+377
-132
lines changed

6 files changed

+377
-132
lines changed

util/opentelemetry-util-genai/CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1010
- Minor change to check LRU cache in Completion Hook before acquiring semaphore/thread ([#3907](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3907)).
1111
- Add environment variable for genai upload hook queue size
1212
([https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3943](#3943))
13+
- Add more Semconv attributes to LLMInvocation spans.
14+
([https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3862](#3862))
1315

1416
## Version 0.2b0 (2025-10-14)
1517

util/opentelemetry-util-genai/pyproject.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,8 @@ classifiers = [
2525
"Programming Language :: Python :: 3.13",
2626
]
2727
dependencies = [
28-
"opentelemetry-instrumentation ~= 0.57b0",
29-
"opentelemetry-semantic-conventions ~= 0.57b0",
28+
"opentelemetry-instrumentation ~= 0.58b0",
29+
"opentelemetry-semantic-conventions ~= 0.58b0",
3030
"opentelemetry-api>=1.31.0",
3131
]
3232

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

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@
6161
from __future__ import annotations
6262

6363
from contextlib import contextmanager
64-
from typing import Iterator, Optional
64+
from typing import Iterator
6565

6666
from opentelemetry import context as otel_context
6767
from opentelemetry.semconv._incubating.attributes import (
@@ -93,7 +93,7 @@ def __init__(self, tracer_provider: TracerProvider | None = None):
9393
__name__,
9494
__version__,
9595
tracer_provider,
96-
schema_url=Schemas.V1_36_0.value,
96+
schema_url=Schemas.V1_37_0.value,
9797
)
9898

9999
def start_llm(
@@ -132,6 +132,7 @@ def fail_llm( # pylint: disable=no-self-use
132132
# TODO: Provide feedback that this invocation was not started
133133
return invocation
134134

135+
_apply_finish_attributes(invocation.span, invocation)
135136
_apply_error_attributes(invocation.span, error)
136137
# Detach context and end span
137138
otel_context.detach(invocation.context_token)
@@ -140,7 +141,7 @@ def fail_llm( # pylint: disable=no-self-use
140141

141142
@contextmanager
142143
def llm(
143-
self, invocation: Optional[LLMInvocation] = None
144+
self, invocation: LLMInvocation | None = None
144145
) -> Iterator[LLMInvocation]:
145146
"""Context manager for LLM invocations.
146147
@@ -169,7 +170,7 @@ def get_telemetry_handler(
169170
"""
170171
Returns a singleton TelemetryHandler instance.
171172
"""
172-
handler: Optional[TelemetryHandler] = getattr(
173+
handler: TelemetryHandler | None = getattr(
173174
get_telemetry_handler, "_default_handler", None
174175
)
175176
if handler is None:

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

Lines changed: 76 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,10 @@
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
1414

15+
from __future__ import annotations
16+
1517
from dataclasses import asdict
16-
from typing import List
18+
from typing import Any
1719

1820
from opentelemetry.semconv._incubating.attributes import (
1921
gen_ai_attributes as GenAI,
@@ -60,32 +62,13 @@ def _apply_common_span_attributes(
6062
# TODO: clean provider name to match GenAiProviderNameValues?
6163
span.set_attribute(GenAI.GEN_AI_PROVIDER_NAME, invocation.provider)
6264

63-
if invocation.output_messages:
64-
span.set_attribute(
65-
GenAI.GEN_AI_RESPONSE_FINISH_REASONS,
66-
[gen.finish_reason for gen in invocation.output_messages],
67-
)
68-
69-
if invocation.response_model_name is not None:
70-
span.set_attribute(
71-
GenAI.GEN_AI_RESPONSE_MODEL, invocation.response_model_name
72-
)
73-
if invocation.response_id is not None:
74-
span.set_attribute(GenAI.GEN_AI_RESPONSE_ID, invocation.response_id)
75-
if invocation.input_tokens is not None:
76-
span.set_attribute(
77-
GenAI.GEN_AI_USAGE_INPUT_TOKENS, invocation.input_tokens
78-
)
79-
if invocation.output_tokens is not None:
80-
span.set_attribute(
81-
GenAI.GEN_AI_USAGE_OUTPUT_TOKENS, invocation.output_tokens
82-
)
65+
_apply_response_attributes(span, invocation)
8366

8467

8568
def _maybe_set_span_messages(
8669
span: Span,
87-
input_messages: List[InputMessage],
88-
output_messages: List[OutputMessage],
70+
input_messages: list[InputMessage],
71+
output_messages: list[OutputMessage],
8972
) -> None:
9073
if not is_experimental_mode() or get_content_capturing_mode() not in (
9174
ContentCapturingMode.SPAN_ONLY,
@@ -112,6 +95,8 @@ def _apply_finish_attributes(span: Span, invocation: LLMInvocation) -> None:
11295
_maybe_set_span_messages(
11396
span, invocation.input_messages, invocation.output_messages
11497
)
98+
_apply_request_attributes(span, invocation)
99+
_apply_response_attributes(span, invocation)
115100
span.set_attributes(invocation.attributes)
116101

117102

@@ -122,7 +107,75 @@ def _apply_error_attributes(span: Span, error: Error) -> None:
122107
span.set_attribute(ErrorAttributes.ERROR_TYPE, error.type.__qualname__)
123108

124109

110+
def _apply_request_attributes(span: Span, invocation: LLMInvocation) -> None:
111+
"""Attach GenAI request semantic convention attributes to the span."""
112+
attributes: dict[str, Any] = {}
113+
if invocation.temperature is not None:
114+
attributes[GenAI.GEN_AI_REQUEST_TEMPERATURE] = invocation.temperature
115+
if invocation.top_p is not None:
116+
attributes[GenAI.GEN_AI_REQUEST_TOP_P] = invocation.top_p
117+
if invocation.frequency_penalty is not None:
118+
attributes[GenAI.GEN_AI_REQUEST_FREQUENCY_PENALTY] = (
119+
invocation.frequency_penalty
120+
)
121+
if invocation.presence_penalty is not None:
122+
attributes[GenAI.GEN_AI_REQUEST_PRESENCE_PENALTY] = (
123+
invocation.presence_penalty
124+
)
125+
if invocation.max_tokens is not None:
126+
attributes[GenAI.GEN_AI_REQUEST_MAX_TOKENS] = invocation.max_tokens
127+
if invocation.stop_sequences is not None:
128+
attributes[GenAI.GEN_AI_REQUEST_STOP_SEQUENCES] = (
129+
invocation.stop_sequences
130+
)
131+
if invocation.seed is not None:
132+
attributes[GenAI.GEN_AI_REQUEST_SEED] = invocation.seed
133+
if attributes:
134+
span.set_attributes(attributes)
135+
136+
137+
def _apply_response_attributes(span: Span, invocation: LLMInvocation) -> None:
138+
"""Attach GenAI response semantic convention attributes to the span."""
139+
attributes: dict[str, Any] = {}
140+
141+
finish_reasons: list[str] | None
142+
if invocation.finish_reasons is not None:
143+
finish_reasons = invocation.finish_reasons
144+
elif invocation.output_messages:
145+
finish_reasons = [
146+
message.finish_reason
147+
for message in invocation.output_messages
148+
if message.finish_reason
149+
]
150+
else:
151+
finish_reasons = None
152+
153+
if finish_reasons:
154+
# De-duplicate finish reasons
155+
unique_finish_reasons = sorted(set(finish_reasons))
156+
if unique_finish_reasons:
157+
attributes[GenAI.GEN_AI_RESPONSE_FINISH_REASONS] = (
158+
unique_finish_reasons
159+
)
160+
161+
if invocation.response_model_name is not None:
162+
attributes[GenAI.GEN_AI_RESPONSE_MODEL] = (
163+
invocation.response_model_name
164+
)
165+
if invocation.response_id is not None:
166+
attributes[GenAI.GEN_AI_RESPONSE_ID] = invocation.response_id
167+
if invocation.input_tokens is not None:
168+
attributes[GenAI.GEN_AI_USAGE_INPUT_TOKENS] = invocation.input_tokens
169+
if invocation.output_tokens is not None:
170+
attributes[GenAI.GEN_AI_USAGE_OUTPUT_TOKENS] = invocation.output_tokens
171+
172+
if attributes:
173+
span.set_attributes(attributes)
174+
175+
125176
__all__ = [
126177
"_apply_finish_attributes",
127178
"_apply_error_attributes",
179+
"_apply_request_attributes",
180+
"_apply_response_attributes",
128181
]

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

Lines changed: 26 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,12 @@
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
1414

15+
from __future__ import annotations
1516

1617
from contextvars import Token
1718
from dataclasses import dataclass, field
1819
from enum import Enum
19-
from typing import Any, Dict, List, Literal, Optional, Type, Union
20+
from typing import Any, Literal, Type, Union
2021

2122
from typing_extensions import TypeAlias
2223

@@ -41,14 +42,14 @@ class ContentCapturingMode(Enum):
4142
class ToolCall:
4243
arguments: Any
4344
name: str
44-
id: Optional[str]
45+
id: str | None
4546
type: Literal["tool_call"] = "tool_call"
4647

4748

4849
@dataclass()
4950
class ToolCallResponse:
5051
response: Any
51-
id: Optional[str]
52+
id: str | None
5253
type: Literal["tool_call_response"] = "tool_call_response"
5354

5455

@@ -76,18 +77,18 @@ class InputMessage:
7677
class OutputMessage:
7778
role: str
7879
parts: list[MessagePart]
79-
finish_reason: Union[str, FinishReason]
80+
finish_reason: str | FinishReason
8081

8182

82-
def _new_input_messages() -> List[InputMessage]:
83+
def _new_input_messages() -> list[InputMessage]:
8384
return []
8485

8586

86-
def _new_output_messages() -> List[OutputMessage]:
87+
def _new_output_messages() -> list[OutputMessage]:
8788
return []
8889

8990

90-
def _new_str_any_dict() -> Dict[str, Any]:
91+
def _new_str_any_dict() -> dict[str, Any]:
9192
return {}
9293

9394

@@ -100,20 +101,28 @@ class LLMInvocation:
100101
"""
101102

102103
request_model: str
103-
context_token: Optional[ContextToken] = None
104-
span: Optional[Span] = None
105-
input_messages: List[InputMessage] = field(
104+
context_token: ContextToken | None = None
105+
span: Span | None = None
106+
input_messages: list[InputMessage] = field(
106107
default_factory=_new_input_messages
107108
)
108-
output_messages: List[OutputMessage] = field(
109+
output_messages: list[OutputMessage] = field(
109110
default_factory=_new_output_messages
110111
)
111-
provider: Optional[str] = None
112-
response_model_name: Optional[str] = None
113-
response_id: Optional[str] = None
114-
input_tokens: Optional[int] = None
115-
output_tokens: Optional[int] = None
116-
attributes: Dict[str, Any] = field(default_factory=_new_str_any_dict)
112+
provider: str | None = None
113+
response_model_name: str | None = None
114+
response_id: str | None = None
115+
finish_reasons: list[str] | None = None
116+
input_tokens: int | None = None
117+
output_tokens: int | None = None
118+
attributes: dict[str, Any] = field(default_factory=_new_str_any_dict)
119+
temperature: float | None = None
120+
top_p: float | None = None
121+
frequency_penalty: float | None = None
122+
presence_penalty: float | None = None
123+
max_tokens: int | None = None
124+
stop_sequences: list[str] | None = None
125+
seed: int | None = None
117126

118127

119128
@dataclass

0 commit comments

Comments
 (0)