Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
1 change: 1 addition & 0 deletions util/opentelemetry-util-genai/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## Unreleased

- Add support for emitting inference events and enrich message types. ([#3994](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3994))
- Minor change to check LRU cache in Completion Hook before acquiring semaphore/thread ([#3907](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3907)).
- Add environment variable for genai upload hook queue size
([https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3943](#3943))
Expand Down
11 changes: 10 additions & 1 deletion util/opentelemetry-util-genai/README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,11 @@ while providing standardization for generating both types of otel, "spans and me
This package relies on environment variables to configure capturing of message content.
By default, message content will not be captured.
Set the environment variable `OTEL_SEMCONV_STABILITY_OPT_IN` to `gen_ai_latest_experimental` to enable experimental features.
And set the environment variable `OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT` to `SPAN_ONLY` or `SPAN_AND_EVENT` to capture message content in spans.
And set the environment variable `OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT` to one of:
- `NO_CONTENT`: Do not capture message content (default).
- `SPAN_ONLY`: Capture message content in spans only.
- `EVENT_ONLY`: Capture message content in events only.
- `SPAN_AND_EVENT`: Capture message content in both spans and events.

This package provides these span attributes:

Expand All @@ -23,6 +27,11 @@ This package provides these span attributes:
- `gen_ai.usage.output_tokens`: Int(7)
- `gen_ai.input.messages`: Str('[{"role": "Human", "parts": [{"content": "hello world", "type": "text"}]}]')
- `gen_ai.output.messages`: Str('[{"role": "AI", "parts": [{"content": "hello back", "type": "text"}], "finish_reason": "stop"}]')
- `gen_ai.system_instructions`: Str('[{"content": "You are a helpful assistant.", "type": "text"}]') (when system instruction is provided)

When `EVENT_ONLY` or `SPAN_AND_EVENT` mode is enabled and a LoggerProvider is configured,
the package also emits `gen_ai.client.inference.operation.details` events with structured
message content (as dictionaries instead of JSON strings).


Installation
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,10 @@
from typing import Iterator

from opentelemetry import context as otel_context
from opentelemetry._logs import (
LoggerProvider,
get_logger,
)
from opentelemetry.metrics import MeterProvider, get_meter
from opentelemetry.semconv._incubating.attributes import (
gen_ai_attributes as GenAI,
Expand All @@ -80,7 +84,8 @@
from opentelemetry.util.genai.metrics import InvocationMetricsRecorder
from opentelemetry.util.genai.span_utils import (
_apply_error_attributes,
_apply_finish_attributes,
_apply_llm_finish_attributes,
_maybe_emit_llm_event,
)
from opentelemetry.util.genai.types import Error, LLMInvocation
from opentelemetry.util.genai.version import __version__
Expand All @@ -96,6 +101,7 @@ def __init__(
self,
tracer_provider: TracerProvider | None = None,
meter_provider: MeterProvider | None = None,
logger_provider: LoggerProvider | None = None,
):
self._tracer = get_tracer(
__name__,
Expand All @@ -106,6 +112,12 @@ def __init__(
self._metrics_recorder: InvocationMetricsRecorder | None = None
meter = get_meter(__name__, meter_provider=meter_provider)
self._metrics_recorder = InvocationMetricsRecorder(meter)
self._logger = get_logger(
__name__,
__version__,
logger_provider,
schema_url=Schemas.V1_37_0.value,
)

def _record_llm_metrics(
self,
Expand Down Expand Up @@ -148,8 +160,9 @@ def stop_llm(self, invocation: LLMInvocation) -> LLMInvocation: # pylint: disab
return invocation

span = invocation.span
_apply_finish_attributes(span, invocation)
_apply_llm_finish_attributes(span, invocation)
self._record_llm_metrics(invocation, span)
_maybe_emit_llm_event(self._logger, span, invocation)
# Detach context and end span
otel_context.detach(invocation.context_token)
span.end()
Expand All @@ -164,10 +177,11 @@ def fail_llm( # pylint: disable=no-self-use
return invocation

span = invocation.span
_apply_finish_attributes(invocation.span, invocation)
_apply_error_attributes(span, error)
_apply_llm_finish_attributes(invocation.span, invocation)
_apply_error_attributes(invocation.span, error)
error_type = getattr(error.type, "__qualname__", None)
self._record_llm_metrics(invocation, span, error_type=error_type)
_maybe_emit_llm_event(self._logger, span, invocation, error)
# Detach context and end span
otel_context.detach(invocation.context_token)
span.end()
Expand Down Expand Up @@ -201,6 +215,7 @@ def llm(
def get_telemetry_handler(
tracer_provider: TracerProvider | None = None,
meter_provider: MeterProvider | None = None,
logger_provider: LoggerProvider | None = None,
) -> TelemetryHandler:
"""
Returns a singleton TelemetryHandler instance.
Expand All @@ -212,6 +227,7 @@ def get_telemetry_handler(
handler = TelemetryHandler(
tracer_provider=tracer_provider,
meter_provider=meter_provider,
logger_provider=logger_provider,
)
setattr(get_telemetry_handler, "_default_handler", handler)
return handler
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@
from dataclasses import asdict
from typing import Any

from opentelemetry._logs import Logger, LogRecord
from opentelemetry.context import get_current
from opentelemetry.semconv._incubating.attributes import (
gen_ai_attributes as GenAI,
)
Expand All @@ -26,11 +28,13 @@
from opentelemetry.trace import (
Span,
)
from opentelemetry.trace.propagation import set_span_in_context
from opentelemetry.trace.status import Status, StatusCode
from opentelemetry.util.genai.types import (
Error,
InputMessage,
LLMInvocation,
MessagePart,
OutputMessage,
)
from opentelemetry.util.genai.utils import (
Expand All @@ -41,63 +45,162 @@
)


def _apply_common_span_attributes(
span: Span, invocation: LLMInvocation
) -> None:
"""Apply attributes shared by finish() and error() and compute metrics.
def _get_llm_common_attributes(
invocation: LLMInvocation,
) -> dict[str, Any]:
"""Get common LLM attributes shared by finish() and error() paths.

Returns (genai_attributes) for use with metrics.
Returns a dictionary of attributes.
"""
span.update_name(
f"{GenAI.GenAiOperationNameValues.CHAT.value} {invocation.request_model}".strip()
)
span.set_attribute(
GenAI.GEN_AI_OPERATION_NAME, GenAI.GenAiOperationNameValues.CHAT.value
attributes: dict[str, Any] = {}
attributes[GenAI.GEN_AI_OPERATION_NAME] = (
GenAI.GenAiOperationNameValues.CHAT.value
)
if invocation.request_model:
span.set_attribute(
GenAI.GEN_AI_REQUEST_MODEL, invocation.request_model
)
attributes[GenAI.GEN_AI_REQUEST_MODEL] = invocation.request_model
if invocation.provider is not None:
# TODO: clean provider name to match GenAiProviderNameValues?
span.set_attribute(GenAI.GEN_AI_PROVIDER_NAME, invocation.provider)
attributes[GenAI.GEN_AI_PROVIDER_NAME] = invocation.provider
return attributes

_apply_response_attributes(span, invocation)

def _get_llm_span_name(invocation: LLMInvocation) -> str:
"""Get the span name for an LLM invocation."""
return f"{GenAI.GenAiOperationNameValues.CHAT.value} {invocation.request_model}".strip()

def _maybe_set_span_messages(
span: Span,

def _get_llm_messages_attributes_for_span(
input_messages: list[InputMessage],
output_messages: list[OutputMessage],
) -> None:
system_instruction: list[MessagePart] | None = None,
) -> dict[str, Any]:
"""Get message attributes formatted for span (JSON string format).

Returns empty dict if not in experimental mode or content capturing is disabled.
"""
attributes: dict[str, Any] = {}
if not is_experimental_mode() or get_content_capturing_mode() not in (
ContentCapturingMode.SPAN_ONLY,
ContentCapturingMode.SPAN_AND_EVENT,
):
return
return attributes
if input_messages:
span.set_attribute(
GenAI.GEN_AI_INPUT_MESSAGES,
gen_ai_json_dumps([asdict(message) for message in input_messages]),
attributes[GenAI.GEN_AI_INPUT_MESSAGES] = gen_ai_json_dumps(
[asdict(message) for message in input_messages]
)
if output_messages:
span.set_attribute(
GenAI.GEN_AI_OUTPUT_MESSAGES,
gen_ai_json_dumps(
[asdict(message) for message in output_messages]
),
attributes[GenAI.GEN_AI_OUTPUT_MESSAGES] = gen_ai_json_dumps(
[asdict(message) for message in output_messages]
)
if system_instruction:
attributes[GenAI.GEN_AI_SYSTEM_INSTRUCTIONS] = gen_ai_json_dumps(
[asdict(part) for part in system_instruction]
)
return attributes


def _get_llm_messages_attributes_for_event(
input_messages: list[InputMessage],
output_messages: list[OutputMessage],
system_instruction: list[MessagePart] | None = None,
) -> dict[str, Any]:
"""Get message attributes formatted for event (structured format).

Returns empty dict if not in experimental mode or content capturing is disabled.
"""
attributes: dict[str, Any] = {}
if not is_experimental_mode() or get_content_capturing_mode() not in (
ContentCapturingMode.EVENT_ONLY,
ContentCapturingMode.SPAN_AND_EVENT,
):
return attributes
if input_messages:
attributes[GenAI.GEN_AI_INPUT_MESSAGES] = [
asdict(message) for message in input_messages
]
if output_messages:
attributes[GenAI.GEN_AI_OUTPUT_MESSAGES] = [
asdict(message) for message in output_messages
]
if system_instruction:
attributes[GenAI.GEN_AI_SYSTEM_INSTRUCTIONS] = [
asdict(part) for part in system_instruction
]
return attributes

def _apply_finish_attributes(span: Span, invocation: LLMInvocation) -> None:

def _maybe_emit_llm_event(
logger: Logger | None,
span: Span,
invocation: LLMInvocation,
error: Error | None = None,
) -> None:
"""Emit a gen_ai.client.inference.operation.details event to the logger.

This function creates a LogRecord event following the semantic convention
for gen_ai.client.inference.operation.details as specified in the GenAI
event semantic conventions.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: can we link to the sem conv that defines this event

"""
if not is_experimental_mode() or get_content_capturing_mode() not in (
ContentCapturingMode.EVENT_ONLY,
ContentCapturingMode.SPAN_AND_EVENT,
):
return
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this isn't right, this flag is for putting content attributes on the event, it shouldn't block the event being written altogether

Copy link
Member Author

@Cirilla-zmh Cirilla-zmh Dec 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@DylanRussell

You have a point, we might need an extra config flag to control inference event emission. But I have to ask: if the inference events don't log the chat history and just keep other attributes, how valuable are they really, since we already have inference spans?

IMO, recording the chat history is one of the main purposes of inference events.

Copy link
Contributor

@DylanRussell DylanRussell Dec 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

today in Google AI instrumentations we always write the events.. They still have some value I think, that's why the flag was introduced not to just enable/disable the entire event, but just the sensitive content on the event..


if logger is None:
return

# Build event attributes by reusing the attribute getter functions
attributes: dict[str, Any] = {}
attributes.update(_get_llm_common_attributes(invocation))
attributes.update(_get_llm_request_attributes(invocation))
attributes.update(_get_llm_response_attributes(invocation))
attributes.update(
_get_llm_messages_attributes_for_event(
invocation.input_messages,
Comment on lines +153 to +160
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this could be one line I think ?

attributes.update(get_dict | get_dict | get_dict...)

invocation.output_messages,
invocation.system_instruction,
)
)

# Add error.type if operation ended in error
if error is not None:
attributes[ErrorAttributes.ERROR_TYPE] = error.type.__qualname__

# Create and emit the event
context = set_span_in_context(span, get_current())
event = LogRecord(
event_name="gen_ai.client.inference.operation.details",
attributes=attributes,
context=context,
)
logger.emit(event)


def _apply_llm_finish_attributes(
span: Span, invocation: LLMInvocation
) -> None:
"""Apply attributes/messages common to finish() paths."""
_apply_common_span_attributes(span, invocation)
_maybe_set_span_messages(
span, invocation.input_messages, invocation.output_messages
# Update span name
span.update_name(_get_llm_span_name(invocation))

# Build all attributes by reusing the attribute getter functions
attributes: dict[str, Any] = {}
attributes.update(_get_llm_common_attributes(invocation))
attributes.update(_get_llm_request_attributes(invocation))
attributes.update(_get_llm_response_attributes(invocation))
attributes.update(
_get_llm_messages_attributes_for_span(
invocation.input_messages,
invocation.output_messages,
invocation.system_instruction,
)
)
_apply_request_attributes(span, invocation)
_apply_response_attributes(span, invocation)
span.set_attributes(invocation.attributes)
attributes.update(invocation.attributes)

# Set all attributes on the span
if attributes:
span.set_attributes(attributes)


def _apply_error_attributes(span: Span, error: Error) -> None:
Expand All @@ -107,8 +210,10 @@ 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."""
def _get_llm_request_attributes(
invocation: LLMInvocation,
) -> dict[str, Any]:
"""Get GenAI request semantic convention attributes."""
attributes: dict[str, Any] = {}
if invocation.temperature is not None:
attributes[GenAI.GEN_AI_REQUEST_TEMPERATURE] = invocation.temperature
Expand All @@ -130,12 +235,13 @@ def _apply_request_attributes(span: Span, invocation: LLMInvocation) -> None:
)
if invocation.seed is not None:
attributes[GenAI.GEN_AI_REQUEST_SEED] = invocation.seed
if attributes:
span.set_attributes(attributes)
return attributes


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

finish_reasons: list[str] | None
Expand Down Expand Up @@ -169,13 +275,15 @@ def _apply_response_attributes(span: Span, invocation: LLMInvocation) -> None:
if invocation.output_tokens is not None:
attributes[GenAI.GEN_AI_USAGE_OUTPUT_TOKENS] = invocation.output_tokens

if attributes:
span.set_attributes(attributes)
return attributes


__all__ = [
"_apply_finish_attributes",
"_apply_llm_finish_attributes",
"_apply_error_attributes",
"_apply_request_attributes",
"_apply_response_attributes",
"_get_llm_common_attributes",
"_get_llm_request_attributes",
"_get_llm_response_attributes",
"_get_llm_span_name",
"_maybe_emit_llm_event",
]
Loading
Loading