Skip to content

Commit eebf58e

Browse files
committed
Add support for emitting inference events and enrich message types
Change-Id: I8fd0b896fc103a986f78c7351ce627611e545a62 Co-developed-by: Cursor <[email protected]>
1 parent 61641aa commit eebf58e

File tree

6 files changed

+486
-57
lines changed

6 files changed

+486
-57
lines changed

util/opentelemetry-util-genai/CHANGELOG.md

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

88
## Unreleased
99

10+
- Add support for emitting inference events and enrich message types. ([]())
1011
- Minor change to check LRU cache in Completion Hook before acquiring semaphore/thread ([#3907](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3907)).
1112
- Add environment variable for genai upload hook queue size
1213
([https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3943](#3943))

util/opentelemetry-util-genai/README.rst

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,11 @@ while providing standardization for generating both types of otel, "spans and me
99
This package relies on environment variables to configure capturing of message content.
1010
By default, message content will not be captured.
1111
Set the environment variable `OTEL_SEMCONV_STABILITY_OPT_IN` to `gen_ai_latest_experimental` to enable experimental features.
12-
And set the environment variable `OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT` to `SPAN_ONLY` or `SPAN_AND_EVENT` to capture message content in spans.
12+
And set the environment variable `OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT` to one of:
13+
- `NO_CONTENT`: Do not capture message content (default).
14+
- `SPAN_ONLY`: Capture message content in spans only.
15+
- `EVENT_ONLY`: Capture message content in events only.
16+
- `SPAN_AND_EVENT`: Capture message content in both spans and events.
1317

1418
This package provides these span attributes:
1519

@@ -23,6 +27,11 @@ This package provides these span attributes:
2327
- `gen_ai.usage.output_tokens`: Int(7)
2428
- `gen_ai.input.messages`: Str('[{"role": "Human", "parts": [{"content": "hello world", "type": "text"}]}]')
2529
- `gen_ai.output.messages`: Str('[{"role": "AI", "parts": [{"content": "hello back", "type": "text"}], "finish_reason": "stop"}]')
30+
- `gen_ai.system.instructions`: Str('[{"content": "You are a helpful assistant.", "type": "text"}]') (when system instruction is provided)
31+
32+
When `EVENT_ONLY` or `SPAN_AND_EVENT` mode is enabled and a LoggerProvider is configured,
33+
the package also emits `gen_ai.client.inference.operation.details` events with structured
34+
message content (as dictionaries instead of JSON strings).
2635

2736

2837
Installation

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

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,10 @@
6666

6767
from opentelemetry import context as otel_context
6868
from opentelemetry.metrics import MeterProvider, get_meter
69+
from opentelemetry._logs import (
70+
LoggerProvider,
71+
get_logger,
72+
)
6973
from opentelemetry.semconv._incubating.attributes import (
7074
gen_ai_attributes as GenAI,
7175
)
@@ -80,7 +84,8 @@
8084
from opentelemetry.util.genai.metrics import InvocationMetricsRecorder
8185
from opentelemetry.util.genai.span_utils import (
8286
_apply_error_attributes,
83-
_apply_finish_attributes,
87+
_apply_llm_finish_attributes,
88+
_maybe_emit_llm_event,
8489
)
8590
from opentelemetry.util.genai.types import Error, LLMInvocation
8691
from opentelemetry.util.genai.version import __version__
@@ -96,6 +101,7 @@ def __init__(
96101
self,
97102
tracer_provider: TracerProvider | None = None,
98103
meter_provider: MeterProvider | None = None,
104+
logger_provider: LoggerProvider | None = None,
99105
):
100106
self._tracer = get_tracer(
101107
__name__,
@@ -106,6 +112,12 @@ def __init__(
106112
self._metrics_recorder: InvocationMetricsRecorder | None = None
107113
meter = get_meter(__name__, meter_provider=meter_provider)
108114
self._metrics_recorder = InvocationMetricsRecorder(meter)
115+
self._logger = get_logger(
116+
__name__,
117+
__version__,
118+
logger_provider,
119+
schema_url=Schemas.V1_37_0.value,
120+
)
109121

110122
def _record_llm_metrics(
111123
self,
@@ -148,8 +160,9 @@ def stop_llm(self, invocation: LLMInvocation) -> LLMInvocation: # pylint: disab
148160
return invocation
149161

150162
span = invocation.span
151-
_apply_finish_attributes(span, invocation)
163+
_apply_llm_finish_attributes(span, invocation)
152164
self._record_llm_metrics(invocation, span)
165+
_maybe_emit_llm_event(self._logger, invocation)
153166
# Detach context and end span
154167
otel_context.detach(invocation.context_token)
155168
span.end()
@@ -164,10 +177,11 @@ def fail_llm( # pylint: disable=no-self-use
164177
return invocation
165178

166179
span = invocation.span
167-
_apply_finish_attributes(invocation.span, invocation)
168-
_apply_error_attributes(span, error)
180+
_apply_llm_finish_attributes(invocation.span, invocation)
181+
_apply_error_attributes(invocation.span, error)
169182
error_type = getattr(error.type, "__qualname__", None)
170183
self._record_llm_metrics(invocation, span, error_type=error_type)
184+
_maybe_emit_llm_event(self._logger, invocation, error)
171185
# Detach context and end span
172186
otel_context.detach(invocation.context_token)
173187
span.end()
@@ -201,6 +215,7 @@ def llm(
201215
def get_telemetry_handler(
202216
tracer_provider: TracerProvider | None = None,
203217
meter_provider: MeterProvider | None = None,
218+
logger_provider: LoggerProvider | None = None,
204219
) -> TelemetryHandler:
205220
"""
206221
Returns a singleton TelemetryHandler instance.
@@ -212,6 +227,7 @@ def get_telemetry_handler(
212227
handler = TelemetryHandler(
213228
tracer_provider=tracer_provider,
214229
meter_provider=meter_provider,
230+
logger_provider=logger_provider
215231
)
216232
setattr(get_telemetry_handler, "_default_handler", handler)
217233
return handler

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

Lines changed: 148 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
from dataclasses import asdict
1818
from typing import Any
1919

20+
from opentelemetry._logs import Logger, LogRecord
2021
from opentelemetry.semconv._incubating.attributes import (
2122
gen_ai_attributes as GenAI,
2223
)
@@ -31,6 +32,7 @@
3132
Error,
3233
InputMessage,
3334
LLMInvocation,
35+
MessagePart,
3436
OutputMessage,
3537
)
3638
from opentelemetry.util.genai.utils import (
@@ -41,63 +43,159 @@
4143
)
4244

4345

44-
def _apply_common_span_attributes(
45-
span: Span, invocation: LLMInvocation
46-
) -> None:
47-
"""Apply attributes shared by finish() and error() and compute metrics.
46+
def _get_llm_common_attributes(
47+
invocation: LLMInvocation,
48+
) -> dict[str, Any]:
49+
"""Get common LLM attributes shared by finish() and error() paths.
4850
49-
Returns (genai_attributes) for use with metrics.
51+
Returns a dictionary of attributes.
5052
"""
51-
span.update_name(
52-
f"{GenAI.GenAiOperationNameValues.CHAT.value} {invocation.request_model}".strip()
53-
)
54-
span.set_attribute(
55-
GenAI.GEN_AI_OPERATION_NAME, GenAI.GenAiOperationNameValues.CHAT.value
53+
attributes: dict[str, Any] = {}
54+
attributes[GenAI.GEN_AI_OPERATION_NAME] = (
55+
GenAI.GenAiOperationNameValues.CHAT.value
5656
)
5757
if invocation.request_model:
58-
span.set_attribute(
59-
GenAI.GEN_AI_REQUEST_MODEL, invocation.request_model
60-
)
58+
attributes[GenAI.GEN_AI_REQUEST_MODEL] = invocation.request_model
6159
if invocation.provider is not None:
6260
# TODO: clean provider name to match GenAiProviderNameValues?
63-
span.set_attribute(GenAI.GEN_AI_PROVIDER_NAME, invocation.provider)
61+
attributes[GenAI.GEN_AI_PROVIDER_NAME] = invocation.provider
62+
return attributes
63+
6464

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

6769

68-
def _maybe_set_span_messages(
69-
span: Span,
70+
def _get_llm_messages_attributes_for_span(
7071
input_messages: list[InputMessage],
7172
output_messages: list[OutputMessage],
72-
) -> None:
73+
system_instruction: list[MessagePart] | None = None,
74+
) -> dict[str, Any]:
75+
"""Get message attributes formatted for span (JSON string format).
76+
77+
Returns empty dict if not in experimental mode or content capturing is disabled.
78+
"""
79+
attributes: dict[str, Any] = {}
7380
if not is_experimental_mode() or get_content_capturing_mode() not in (
7481
ContentCapturingMode.SPAN_ONLY,
7582
ContentCapturingMode.SPAN_AND_EVENT,
7683
):
77-
return
84+
return attributes
7885
if input_messages:
79-
span.set_attribute(
80-
GenAI.GEN_AI_INPUT_MESSAGES,
81-
gen_ai_json_dumps([asdict(message) for message in input_messages]),
86+
attributes[GenAI.GEN_AI_INPUT_MESSAGES] = gen_ai_json_dumps(
87+
[asdict(message) for message in input_messages]
8288
)
8389
if output_messages:
84-
span.set_attribute(
85-
GenAI.GEN_AI_OUTPUT_MESSAGES,
86-
gen_ai_json_dumps(
87-
[asdict(message) for message in output_messages]
88-
),
90+
attributes[GenAI.GEN_AI_OUTPUT_MESSAGES] = gen_ai_json_dumps(
91+
[asdict(message) for message in output_messages]
92+
)
93+
if system_instruction:
94+
attributes[GenAI.GEN_AI_SYSTEM_INSTRUCTIONS] = gen_ai_json_dumps(
95+
[asdict(part) for part in system_instruction]
8996
)
97+
return attributes
9098

9199

92-
def _apply_finish_attributes(span: Span, invocation: LLMInvocation) -> None:
100+
def _get_llm_messages_attributes_for_event(
101+
input_messages: list[InputMessage],
102+
output_messages: list[OutputMessage],
103+
system_instruction: list[MessagePart] | None = None,
104+
) -> dict[str, Any]:
105+
"""Get message attributes formatted for event (structured format).
106+
107+
Returns empty dict if not in experimental mode or content capturing is disabled.
108+
"""
109+
attributes: dict[str, Any] = {}
110+
if not is_experimental_mode() or get_content_capturing_mode() not in (
111+
ContentCapturingMode.EVENT_ONLY,
112+
ContentCapturingMode.SPAN_AND_EVENT,
113+
):
114+
return attributes
115+
if input_messages:
116+
attributes[GenAI.GEN_AI_INPUT_MESSAGES] = [
117+
asdict(message) for message in input_messages
118+
]
119+
if output_messages:
120+
attributes[GenAI.GEN_AI_OUTPUT_MESSAGES] = [
121+
asdict(message) for message in output_messages
122+
]
123+
if system_instruction:
124+
attributes[GenAI.GEN_AI_SYSTEM_INSTRUCTIONS] = [
125+
asdict(part) for part in system_instruction
126+
]
127+
return attributes
128+
129+
130+
def _maybe_emit_llm_event(
131+
logger: Logger | None,
132+
invocation: LLMInvocation,
133+
error: Error | None = None,
134+
) -> None:
135+
"""Emit a gen_ai.client.inference.operation.details event to the logger.
136+
137+
This function creates a LogRecord event following the semantic convention
138+
for gen_ai.client.inference.operation.details as specified in the GenAI
139+
event semantic conventions.
140+
"""
141+
if not is_experimental_mode() or get_content_capturing_mode() not in (
142+
ContentCapturingMode.EVENT_ONLY,
143+
ContentCapturingMode.SPAN_AND_EVENT,
144+
):
145+
return
146+
147+
if logger is None:
148+
return
149+
150+
# Build event attributes by reusing the attribute getter functions
151+
attributes: dict[str, Any] = {}
152+
attributes.update(_get_llm_common_attributes(invocation))
153+
attributes.update(_get_llm_request_attributes(invocation))
154+
attributes.update(_get_llm_response_attributes(invocation))
155+
attributes.update(
156+
_get_llm_messages_attributes_for_event(
157+
invocation.input_messages,
158+
invocation.output_messages,
159+
invocation.system_instruction,
160+
)
161+
)
162+
163+
# Add error.type if operation ended in error
164+
if error is not None:
165+
attributes[ErrorAttributes.ERROR_TYPE] = error.type.__qualname__
166+
167+
# Create and emit the event
168+
event = LogRecord(
169+
event_name="gen_ai.client.inference.operation.details",
170+
attributes=attributes,
171+
)
172+
logger.emit(event)
173+
174+
175+
def _apply_llm_finish_attributes(
176+
span: Span, invocation: LLMInvocation
177+
) -> None:
93178
"""Apply attributes/messages common to finish() paths."""
94-
_apply_common_span_attributes(span, invocation)
95-
_maybe_set_span_messages(
96-
span, invocation.input_messages, invocation.output_messages
179+
# Update span name
180+
span.update_name(_get_llm_span_name(invocation))
181+
182+
# Build all attributes by reusing the attribute getter functions
183+
attributes: dict[str, Any] = {}
184+
attributes.update(_get_llm_common_attributes(invocation))
185+
attributes.update(_get_llm_request_attributes(invocation))
186+
attributes.update(_get_llm_response_attributes(invocation))
187+
attributes.update(
188+
_get_llm_messages_attributes_for_span(
189+
invocation.input_messages,
190+
invocation.output_messages,
191+
invocation.system_instruction,
192+
)
97193
)
98-
_apply_request_attributes(span, invocation)
99-
_apply_response_attributes(span, invocation)
100-
span.set_attributes(invocation.attributes)
194+
attributes.update(invocation.attributes)
195+
196+
# Set all attributes on the span
197+
if attributes:
198+
span.set_attributes(attributes)
101199

102200

103201
def _apply_error_attributes(span: Span, error: Error) -> None:
@@ -107,8 +205,10 @@ def _apply_error_attributes(span: Span, error: Error) -> None:
107205
span.set_attribute(ErrorAttributes.ERROR_TYPE, error.type.__qualname__)
108206

109207

110-
def _apply_request_attributes(span: Span, invocation: LLMInvocation) -> None:
111-
"""Attach GenAI request semantic convention attributes to the span."""
208+
def _get_llm_request_attributes(
209+
invocation: LLMInvocation,
210+
) -> dict[str, Any]:
211+
"""Get GenAI request semantic convention attributes."""
112212
attributes: dict[str, Any] = {}
113213
if invocation.temperature is not None:
114214
attributes[GenAI.GEN_AI_REQUEST_TEMPERATURE] = invocation.temperature
@@ -130,12 +230,13 @@ def _apply_request_attributes(span: Span, invocation: LLMInvocation) -> None:
130230
)
131231
if invocation.seed is not None:
132232
attributes[GenAI.GEN_AI_REQUEST_SEED] = invocation.seed
133-
if attributes:
134-
span.set_attributes(attributes)
233+
return attributes
135234

136235

137-
def _apply_response_attributes(span: Span, invocation: LLMInvocation) -> None:
138-
"""Attach GenAI response semantic convention attributes to the span."""
236+
def _get_llm_response_attributes(
237+
invocation: LLMInvocation,
238+
) -> dict[str, Any]:
239+
"""Get GenAI response semantic convention attributes."""
139240
attributes: dict[str, Any] = {}
140241

141242
finish_reasons: list[str] | None
@@ -169,13 +270,15 @@ def _apply_response_attributes(span: Span, invocation: LLMInvocation) -> None:
169270
if invocation.output_tokens is not None:
170271
attributes[GenAI.GEN_AI_USAGE_OUTPUT_TOKENS] = invocation.output_tokens
171272

172-
if attributes:
173-
span.set_attributes(attributes)
273+
return attributes
174274

175275

176276
__all__ = [
177-
"_apply_finish_attributes",
277+
"_apply_llm_finish_attributes",
178278
"_apply_error_attributes",
179-
"_apply_request_attributes",
180-
"_apply_response_attributes",
279+
"_get_llm_common_attributes",
280+
"_get_llm_request_attributes",
281+
"_get_llm_response_attributes",
282+
"_get_llm_span_name",
283+
"_maybe_emit_llm_event",
181284
]

0 commit comments

Comments
 (0)