Skip to content
Merged
Show file tree
Hide file tree
Changes from 13 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions util/opentelemetry-util-genai/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## Unreleased

- Add more Semconv attributes to LLMInvocation spans.
([https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3862](#3862))

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

- Add jsonlines support to fsspec uploader
Expand Down
4 changes: 2 additions & 2 deletions util/opentelemetry-util-genai/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,8 @@ classifiers = [
"Programming Language :: Python :: 3.13",
]
dependencies = [
"opentelemetry-instrumentation ~= 0.57b0",
"opentelemetry-semantic-conventions ~= 0.57b0",
"opentelemetry-instrumentation ~= 0.58b0",
"opentelemetry-semantic-conventions ~= 0.58b0",
"opentelemetry-api>=1.31.0",
]

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@
from __future__ import annotations

from contextlib import contextmanager
from typing import Iterator, Optional
from typing import Iterator

from opentelemetry import context as otel_context
from opentelemetry.semconv._incubating.attributes import (
Expand Down Expand Up @@ -93,7 +93,7 @@ def __init__(self, tracer_provider: TracerProvider | None = None):
__name__,
__version__,
tracer_provider,
schema_url=Schemas.V1_36_0.value,
schema_url=Schemas.V1_37_0.value,
)

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

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

@contextmanager
def llm(
self, invocation: Optional[LLMInvocation] = None
self, invocation: LLMInvocation | None = None
) -> Iterator[LLMInvocation]:
"""Context manager for LLM invocations.
Expand Down Expand Up @@ -169,7 +170,7 @@ def get_telemetry_handler(
"""
Returns a singleton TelemetryHandler instance.
"""
handler: Optional[TelemetryHandler] = getattr(
handler: TelemetryHandler | None = getattr(
get_telemetry_handler, "_default_handler", None
)
if handler is None:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,10 @@
# See the License for the specific language governing permissions and
# limitations under the License.

from __future__ import annotations

from dataclasses import asdict
from typing import List
from typing import Any

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

if invocation.output_messages:
span.set_attribute(
GenAI.GEN_AI_RESPONSE_FINISH_REASONS,
[gen.finish_reason for gen in invocation.output_messages],
)

if invocation.response_model_name is not None:
span.set_attribute(
GenAI.GEN_AI_RESPONSE_MODEL, invocation.response_model_name
)
if invocation.response_id is not None:
span.set_attribute(GenAI.GEN_AI_RESPONSE_ID, invocation.response_id)
if invocation.input_tokens is not None:
span.set_attribute(
GenAI.GEN_AI_USAGE_INPUT_TOKENS, invocation.input_tokens
)
if invocation.output_tokens is not None:
span.set_attribute(
GenAI.GEN_AI_USAGE_OUTPUT_TOKENS, invocation.output_tokens
)
_apply_response_attributes(span, invocation)


def _maybe_set_span_messages(
span: Span,
input_messages: List[InputMessage],
output_messages: List[OutputMessage],
input_messages: list[InputMessage],
output_messages: list[OutputMessage],
) -> None:
if not is_experimental_mode() or get_content_capturing_mode() not in (
ContentCapturingMode.SPAN_ONLY,
Expand All @@ -112,6 +95,8 @@ def _apply_finish_attributes(span: Span, invocation: LLMInvocation) -> None:
_maybe_set_span_messages(
span, invocation.input_messages, invocation.output_messages
)
_apply_request_attributes(span, invocation)
_apply_response_attributes(span, invocation)
span.set_attributes(invocation.attributes)


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


def _apply_request_attributes(span: Span, invocation: LLMInvocation) -> None:
"""Attach GenAI request semantic convention attributes to the span."""
attributes: dict[str, Any] = {}
if invocation.temperature is not None:
attributes[GenAI.GEN_AI_REQUEST_TEMPERATURE] = invocation.temperature
if invocation.top_p is not None:
attributes[GenAI.GEN_AI_REQUEST_TOP_P] = invocation.top_p
if invocation.frequency_penalty is not None:
attributes[GenAI.GEN_AI_REQUEST_FREQUENCY_PENALTY] = (
invocation.frequency_penalty
)
if invocation.presence_penalty is not None:
attributes[GenAI.GEN_AI_REQUEST_PRESENCE_PENALTY] = (
invocation.presence_penalty
)
if invocation.max_tokens is not None:
attributes[GenAI.GEN_AI_REQUEST_MAX_TOKENS] = invocation.max_tokens
if invocation.stop_sequences is not None:
attributes[GenAI.GEN_AI_REQUEST_STOP_SEQUENCES] = (
invocation.stop_sequences
)
if invocation.seed is not None:
attributes[GenAI.GEN_AI_REQUEST_SEED] = invocation.seed
if attributes:
span.set_attributes(attributes)


def _apply_response_attributes(span: Span, invocation: LLMInvocation) -> None:
"""Attach GenAI response semantic convention attributes to the span."""
attributes: dict[str, Any] = {}

finish_reasons: list[str] | None
if invocation.finish_reasons is not None:
finish_reasons = invocation.finish_reasons
elif invocation.output_messages:
finish_reasons = [
message.finish_reason
for message in invocation.output_messages
if message.finish_reason
]
else:
finish_reasons = None

if finish_reasons:
# De-duplicate finish reasons
unique_finish_reasons = sorted(set(finish_reasons))
if unique_finish_reasons:
attributes[GenAI.GEN_AI_RESPONSE_FINISH_REASONS] = (
unique_finish_reasons
)

if invocation.response_model_name is not None:
attributes[GenAI.GEN_AI_RESPONSE_MODEL] = (
invocation.response_model_name
)
if invocation.response_id is not None:
attributes[GenAI.GEN_AI_RESPONSE_ID] = invocation.response_id
if invocation.input_tokens is not None:
attributes[GenAI.GEN_AI_USAGE_INPUT_TOKENS] = invocation.input_tokens
if invocation.output_tokens is not None:
attributes[GenAI.GEN_AI_USAGE_OUTPUT_TOKENS] = invocation.output_tokens

if attributes:
span.set_attributes(attributes)


__all__ = [
"_apply_finish_attributes",
"_apply_error_attributes",
"_apply_request_attributes",
"_apply_response_attributes",
]
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,12 @@
# See the License for the specific language governing permissions and
# limitations under the License.

from __future__ import annotations

from contextvars import Token
from dataclasses import dataclass, field
from enum import Enum
from typing import Any, Dict, List, Literal, Optional, Type, Union
from typing import Any, Literal, Type, Union

from typing_extensions import TypeAlias

Expand All @@ -41,14 +42,14 @@ class ContentCapturingMode(Enum):
class ToolCall:
arguments: Any
name: str
id: Optional[str]
id: str | None
type: Literal["tool_call"] = "tool_call"


@dataclass()
class ToolCallResponse:
response: Any
id: Optional[str]
id: str | None
type: Literal["tool_call_response"] = "tool_call_response"


Expand Down Expand Up @@ -76,18 +77,18 @@ class InputMessage:
class OutputMessage:
role: str
parts: list[MessagePart]
finish_reason: Union[str, FinishReason]
finish_reason: str | FinishReason


def _new_input_messages() -> List[InputMessage]:
def _new_input_messages() -> list[InputMessage]:
return []


def _new_output_messages() -> List[OutputMessage]:
def _new_output_messages() -> list[OutputMessage]:
return []


def _new_str_any_dict() -> Dict[str, Any]:
def _new_str_any_dict() -> dict[str, Any]:
return {}


Expand All @@ -100,20 +101,28 @@ class LLMInvocation:
"""

request_model: str
context_token: Optional[ContextToken] = None
span: Optional[Span] = None
input_messages: List[InputMessage] = field(
context_token: ContextToken | None = None
span: Span | None = None
input_messages: list[InputMessage] = field(
default_factory=_new_input_messages
)
output_messages: List[OutputMessage] = field(
output_messages: list[OutputMessage] = field(
default_factory=_new_output_messages
)
provider: Optional[str] = None
response_model_name: Optional[str] = None
response_id: Optional[str] = None
input_tokens: Optional[int] = None
output_tokens: Optional[int] = None
attributes: Dict[str, Any] = field(default_factory=_new_str_any_dict)
provider: str | None = None
response_model_name: str | None = None
response_id: str | None = None
finish_reasons: list[str] | None = None
input_tokens: int | None = None
output_tokens: int | None = None
attributes: dict[str, Any] = field(default_factory=_new_str_any_dict)
temperature: float | None = None
top_p: float | None = None
frequency_penalty: float | None = None
presence_penalty: float | None = None
max_tokens: int | None = None
stop_sequences: list[str] | None = None
seed: int | None = None


@dataclass
Expand Down
Loading