Skip to content

Commit e3d3817

Browse files
GenAI Utils | Inference Type and Span Creation (open-telemetry#3768)
* cherry pick changes from previous PR * move span utils to new file * remove span state, use otel context for parent/child * flatten LLMInvocation to use attributes instead of dict keys * helper function and docstrings * refactor: store span and context token in LLMInvocation instead of SpanGenerator * refactor: rename prompts/chat_generations to input_messages/output_messages for clarity * refactor: simplify TelemetryHandler API by moving invocation data management to LLMInvocation class * refactor: update relative imports to absolute imports * Update handler to use a context manager instead of start_llm and stop_llm * resolve tox -e doc failure * safeguard against empty request-model * fix tox typecheck errors for utils * refactor: move tracer to generator, clean up dead code * remove unused linting hint * back off stricter request-model requirements * reintroduce manual start/stop for langchain callback flow * clean up context handler, clarify unit tests * remove generator concept * update token types * code cleanup * Refactor TestTelemetryHandler to use instance method for span exporter setup * refactor: remove unused type properties * refactor: update TelemetryHandler initialization to remove **kwargs * refactor: remove tracer variable * refactor: code style updates * refactor: replace json.dumps with gen_ai_json_dumps for message serialization * refactor: update span lifecycle to use sdk over setting context manually * refactor: don't reinvent span attribute assignment * refactor: pylint update for python 3.13 * Revert "refactor: update span lifecycle to use sdk over setting context manually" This reverts commit be8620b. --------- Co-authored-by: Aaron Abbott <[email protected]>
1 parent 050d582 commit e3d3817

File tree

10 files changed

+618
-13
lines changed

10 files changed

+618
-13
lines changed

docs/nitpick-exceptions.ini

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ py-class=
4545
psycopg.AsyncConnection
4646
ObjectProxy
4747
fastapi.applications.FastAPI
48+
_contextvars.Token
4849

4950
any=
5051
; API

util/opentelemetry-util-genai/CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,3 +33,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
3333
([#3763](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3763))
3434
- Add a utility to parse the `OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT` environment variable.
3535
Add `gen_ai_latest_experimental` as a new value to the Sem Conv stability flag ([#3716](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3716)).
36+
37+
### Added
38+
39+
- Generate Spans for LLM invocations
40+
- Helper functions for starting and finishing LLM invocations

util/opentelemetry-util-genai/README.rst

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,25 @@ The GenAI Utils package will include boilerplate and helpers to standardize inst
66
This package will provide APIs and decorators to minimize the work needed to instrument genai libraries,
77
while providing standardization for generating both types of otel, "spans and metrics" and "spans, metrics and events"
88

9+
This package relies on environment variables to configure capturing of message content.
10+
By default, message content will not be captured.
11+
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.
13+
14+
This package provides these span attributes:
15+
16+
- `gen_ai.provider.name`: Str(openai)
17+
- `gen_ai.operation.name`: Str(chat)
18+
- `gen_ai.request.model`: Str(gpt-3.5-turbo)
19+
- `gen_ai.response.finish_reasons`: Slice(["stop"])
20+
- `gen_ai.response.model`: Str(gpt-3.5-turbo-0125)
21+
- `gen_ai.response.id`: Str(chatcmpl-Bz8yrvPnydD9pObv625n2CGBPHS13)
22+
- `gen_ai.usage.input_tokens`: Int(24)
23+
- `gen_ai.usage.output_tokens`: Int(7)
24+
- `gen_ai.input.messages`: Str('[{"role": "Human", "parts": [{"content": "hello world", "type": "text"}]}]')
25+
- `gen_ai.output.messages`: Str('[{"role": "AI", "parts": [{"content": "hello back", "type": "text"}], "finish_reason": "stop"}]')
26+
27+
928
Installation
1029
------------
1130

util/opentelemetry-util-genai/pyproject.toml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ dynamic = ["version"]
88
description = "OpenTelemetry GenAI Utils"
99
readme = "README.rst"
1010
license = "Apache-2.0"
11-
requires-python = ">=3.8"
11+
requires-python = ">=3.9"
1212
authors = [
1313
{ name = "OpenTelemetry Authors", email = "[email protected]" },
1414
]
@@ -25,8 +25,8 @@ classifiers = [
2525
"Programming Language :: Python :: 3.13",
2626
]
2727
dependencies = [
28-
"opentelemetry-instrumentation ~= 0.51b0",
29-
"opentelemetry-semantic-conventions ~= 0.51b0",
28+
"opentelemetry-instrumentation ~= 0.57b0",
29+
"opentelemetry-semantic-conventions ~= 0.57b0",
3030
"opentelemetry-api>=1.31.0",
3131
]
3232

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
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.
Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
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+
"""
16+
Telemetry handler for GenAI invocations.
17+
18+
This module exposes the `TelemetryHandler` class, which manages the lifecycle of
19+
GenAI (Generative AI) invocations and emits telemetry data (spans and related attributes).
20+
It supports starting, stopping, and failing LLM invocations.
21+
22+
Classes:
23+
- TelemetryHandler: Manages GenAI invocation lifecycles and emits telemetry.
24+
25+
Functions:
26+
- get_telemetry_handler: Returns a singleton `TelemetryHandler` instance.
27+
28+
Usage:
29+
handler = get_telemetry_handler()
30+
31+
# Create an invocation object with your request data
32+
# The span and context_token attributes are set by the TelemetryHandler, and
33+
# managed by the TelemetryHandler during the lifecycle of the span.
34+
35+
# Use the context manager to manage the lifecycle of an LLM invocation.
36+
with handler.llm(invocation) as invocation:
37+
# Populate outputs and any additional attributes
38+
invocation.output_messages = [...]
39+
invocation.attributes.update({"more": "attrs"})
40+
41+
# Or, if you prefer to manage the lifecycle manually
42+
invocation = LLMInvocation(
43+
request_model="my-model",
44+
input_messages=[...],
45+
provider="my-provider",
46+
attributes={"custom": "attr"},
47+
)
48+
49+
# Start the invocation (opens a span)
50+
handler.start_llm(invocation)
51+
52+
# Populate outputs and any additional attributes, then stop (closes the span)
53+
invocation.output_messages = [...]
54+
invocation.attributes.update({"more": "attrs"})
55+
handler.stop_llm(invocation)
56+
57+
# Or, in case of error
58+
handler.fail_llm(invocation, Error(type="...", message="..."))
59+
"""
60+
61+
from __future__ import annotations
62+
63+
from contextlib import contextmanager
64+
from typing import Iterator, Optional
65+
66+
from opentelemetry import context as otel_context
67+
from opentelemetry.semconv._incubating.attributes import (
68+
gen_ai_attributes as GenAI,
69+
)
70+
from opentelemetry.semconv.schemas import Schemas
71+
from opentelemetry.trace import (
72+
SpanKind,
73+
TracerProvider,
74+
get_tracer,
75+
set_span_in_context,
76+
)
77+
from opentelemetry.util.genai.span_utils import (
78+
_apply_error_attributes,
79+
_apply_finish_attributes,
80+
)
81+
from opentelemetry.util.genai.types import Error, LLMInvocation
82+
from opentelemetry.util.genai.version import __version__
83+
84+
85+
class TelemetryHandler:
86+
"""
87+
High-level handler managing GenAI invocation lifecycles and emitting
88+
them as spans, metrics, and events.
89+
"""
90+
91+
def __init__(self, tracer_provider: TracerProvider | None = None):
92+
self._tracer = get_tracer(
93+
__name__,
94+
__version__,
95+
tracer_provider,
96+
schema_url=Schemas.V1_36_0.value,
97+
)
98+
99+
def start_llm(
100+
self,
101+
invocation: LLMInvocation,
102+
) -> LLMInvocation:
103+
"""Start an LLM invocation and create a pending span entry."""
104+
# Create a span and attach it as current; keep the token to detach later
105+
span = self._tracer.start_span(
106+
name=f"{GenAI.GenAiOperationNameValues.CHAT.value} {invocation.request_model}",
107+
kind=SpanKind.CLIENT,
108+
)
109+
invocation.span = span
110+
invocation.context_token = otel_context.attach(
111+
set_span_in_context(span)
112+
)
113+
return invocation
114+
115+
def stop_llm(self, invocation: LLMInvocation) -> LLMInvocation: # pylint: disable=no-self-use
116+
"""Finalize an LLM invocation successfully and end its span."""
117+
if invocation.context_token is None or invocation.span is None:
118+
# TODO: Provide feedback that this invocation was not started
119+
return invocation
120+
121+
_apply_finish_attributes(invocation.span, invocation)
122+
# Detach context and end span
123+
otel_context.detach(invocation.context_token)
124+
invocation.span.end()
125+
return invocation
126+
127+
def fail_llm( # pylint: disable=no-self-use
128+
self, invocation: LLMInvocation, error: Error
129+
) -> LLMInvocation:
130+
"""Fail an LLM invocation and end its span with error status."""
131+
if invocation.context_token is None or invocation.span is None:
132+
# TODO: Provide feedback that this invocation was not started
133+
return invocation
134+
135+
_apply_error_attributes(invocation.span, error)
136+
# Detach context and end span
137+
otel_context.detach(invocation.context_token)
138+
invocation.span.end()
139+
return invocation
140+
141+
@contextmanager
142+
def llm(
143+
self, invocation: Optional[LLMInvocation] = None
144+
) -> Iterator[LLMInvocation]:
145+
"""Context manager for LLM invocations.
146+
147+
Only set data attributes on the invocation object, do not modify the span or context.
148+
149+
Starts the span on entry. On normal exit, finalizes the invocation and ends the span.
150+
If an exception occurs inside the context, marks the span as error, ends it, and
151+
re-raises the original exception.
152+
"""
153+
if invocation is None:
154+
invocation = LLMInvocation(
155+
request_model="",
156+
)
157+
self.start_llm(invocation)
158+
try:
159+
yield invocation
160+
except Exception as exc:
161+
self.fail_llm(invocation, Error(message=str(exc), type=type(exc)))
162+
raise
163+
self.stop_llm(invocation)
164+
165+
166+
def get_telemetry_handler(
167+
tracer_provider: TracerProvider | None = None,
168+
) -> TelemetryHandler:
169+
"""
170+
Returns a singleton TelemetryHandler instance.
171+
"""
172+
handler: Optional[TelemetryHandler] = getattr(
173+
get_telemetry_handler, "_default_handler", None
174+
)
175+
if handler is None:
176+
handler = TelemetryHandler(tracer_provider=tracer_provider)
177+
setattr(get_telemetry_handler, "_default_handler", handler)
178+
return handler
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
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 dataclasses import asdict
16+
from typing import List
17+
18+
from opentelemetry.semconv._incubating.attributes import (
19+
gen_ai_attributes as GenAI,
20+
)
21+
from opentelemetry.semconv.attributes import (
22+
error_attributes as ErrorAttributes,
23+
)
24+
from opentelemetry.trace import (
25+
Span,
26+
)
27+
from opentelemetry.trace.status import Status, StatusCode
28+
from opentelemetry.util.genai.types import (
29+
Error,
30+
InputMessage,
31+
LLMInvocation,
32+
OutputMessage,
33+
)
34+
from opentelemetry.util.genai.utils import (
35+
ContentCapturingMode,
36+
gen_ai_json_dumps,
37+
get_content_capturing_mode,
38+
is_experimental_mode,
39+
)
40+
41+
42+
def _apply_common_span_attributes(
43+
span: Span, invocation: LLMInvocation
44+
) -> None:
45+
"""Apply attributes shared by finish() and error() and compute metrics.
46+
47+
Returns (genai_attributes) for use with metrics.
48+
"""
49+
span.update_name(
50+
f"{GenAI.GenAiOperationNameValues.CHAT.value} {invocation.request_model}".strip()
51+
)
52+
span.set_attribute(
53+
GenAI.GEN_AI_OPERATION_NAME, GenAI.GenAiOperationNameValues.CHAT.value
54+
)
55+
if invocation.request_model:
56+
span.set_attribute(
57+
GenAI.GEN_AI_REQUEST_MODEL, invocation.request_model
58+
)
59+
if invocation.provider is not None:
60+
# TODO: clean provider name to match GenAiProviderNameValues?
61+
span.set_attribute(GenAI.GEN_AI_PROVIDER_NAME, invocation.provider)
62+
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+
)
83+
84+
85+
def _maybe_set_span_messages(
86+
span: Span,
87+
input_messages: List[InputMessage],
88+
output_messages: List[OutputMessage],
89+
) -> None:
90+
if not is_experimental_mode() or get_content_capturing_mode() not in (
91+
ContentCapturingMode.SPAN_ONLY,
92+
ContentCapturingMode.SPAN_AND_EVENT,
93+
):
94+
return
95+
if input_messages:
96+
span.set_attribute(
97+
GenAI.GEN_AI_INPUT_MESSAGES,
98+
gen_ai_json_dumps([asdict(message) for message in input_messages]),
99+
)
100+
if output_messages:
101+
span.set_attribute(
102+
GenAI.GEN_AI_OUTPUT_MESSAGES,
103+
gen_ai_json_dumps(
104+
[asdict(message) for message in output_messages]
105+
),
106+
)
107+
108+
109+
def _apply_finish_attributes(span: Span, invocation: LLMInvocation) -> None:
110+
"""Apply attributes/messages common to finish() paths."""
111+
_apply_common_span_attributes(span, invocation)
112+
_maybe_set_span_messages(
113+
span, invocation.input_messages, invocation.output_messages
114+
)
115+
span.set_attributes(invocation.attributes)
116+
117+
118+
def _apply_error_attributes(span: Span, error: Error) -> None:
119+
"""Apply status and error attributes common to error() paths."""
120+
span.set_status(Status(StatusCode.ERROR, error.message))
121+
if span.is_recording():
122+
span.set_attribute(ErrorAttributes.ERROR_TYPE, error.type.__qualname__)
123+
124+
125+
__all__ = [
126+
"_apply_finish_attributes",
127+
"_apply_error_attributes",
128+
]

0 commit comments

Comments
 (0)