Skip to content

Commit 465ca78

Browse files
committed
refactor: simplify TelemetryHandler API by moving invocation data management to LLMInvocation class
1 parent 1a172d1 commit 465ca78

File tree

4 files changed

+81
-91
lines changed

4 files changed

+81
-91
lines changed

util/opentelemetry-util-genai/README.rst

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -11,17 +11,18 @@ 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.
1212
And set the environment variable `OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT` to `SPAN_ONLY` or `SPAN_AND_EVENT` to capture message content in spans.
1313

14-
This package provides these span attributes.
15-
-> gen_ai.provider.name: Str(openai)
16-
-> gen_ai.operation.name: Str(chat)
17-
-> gen_ai.request.model: Str(gpt-3.5-turbo)
18-
-> gen_ai.response.finish_reasons: Slice(["stop"])
19-
-> gen_ai.response.model: Str(gpt-3.5-turbo-0125)
20-
-> gen_ai.response.id: Str(chatcmpl-Bz8yrvPnydD9pObv625n2CGBPHS13)
21-
-> gen_ai.usage.input_tokens: Int(24)
22-
-> gen_ai.usage.output_tokens: Int(7)
23-
-> gen_ai.input.messages: Str('[{"role": "Human", "parts": [{"content": "hello world", "type": "text"}]}]')
24-
-> gen_ai.output.messages: Str('[{"role": "AI", "parts": [{"content": "hello back", "type": "text"}], "finish_reason": "stop"}]')
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"}]')
2526

2627

2728
Installation

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

Lines changed: 25 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -27,43 +27,38 @@
2727
2828
Usage:
2929
handler = get_telemetry_handler()
30-
handler.start_llm(input_messages, request_model, **attrs)
31-
handler.stop_llm(invocation, output_messages, **attrs)
32-
handler.fail_llm(invocation, error, **attrs)
30+
31+
# Create an invocation object with your request data
32+
invocation = LLMInvocation(
33+
request_model="my-model",
34+
input_messages=[...],
35+
provider="my-provider",
36+
attributes={"custom": "attr"},
37+
)
38+
39+
# Start the invocation (opens a span)
40+
handler.start_llm(invocation)
41+
42+
# Populate outputs and any additional attributes, then stop (closes the span)
43+
invocation.output_messages = [...]
44+
invocation.attributes.update({"more": "attrs"})
45+
handler.stop_llm(invocation)
46+
47+
# Or, in case of error
48+
# handler.fail_llm(invocation, Error(type="...", message="..."))
3349
"""
3450

3551
import time
36-
from typing import Any, List, Optional
52+
from typing import Any, Optional
3753

3854
from opentelemetry.semconv.schemas import Schemas
3955
from opentelemetry.trace import get_tracer
4056

4157
from .generators import SpanGenerator
42-
from .types import Error, InputMessage, LLMInvocation, OutputMessage
58+
from .types import Error, LLMInvocation
4359
from .version import __version__
4460

4561

46-
def _apply_known_attrs_to_invocation(
47-
invocation: LLMInvocation, attributes: dict[str, Any]
48-
) -> None:
49-
"""Pop known fields from attributes and set them on the invocation.
50-
51-
Mutates the provided attributes dict by popping known keys, leaving
52-
only unknown/custom attributes behind for the caller to persist into
53-
invocation.attributes.
54-
"""
55-
if "provider" in attributes:
56-
invocation.provider = attributes.pop("provider")
57-
if "response_model_name" in attributes:
58-
invocation.response_model_name = attributes.pop("response_model_name")
59-
if "response_id" in attributes:
60-
invocation.response_id = attributes.pop("response_id")
61-
if "input_tokens" in attributes:
62-
invocation.input_tokens = attributes.pop("input_tokens")
63-
if "output_tokens" in attributes:
64-
invocation.output_tokens = attributes.pop("output_tokens")
65-
66-
6762
class TelemetryHandler:
6863
"""
6964
High-level handler managing GenAI invocation lifecycles and emitting
@@ -83,50 +78,23 @@ def __init__(self, **kwargs: Any):
8378

8479
def start_llm(
8580
self,
86-
request_model: str,
87-
input_messages: List[InputMessage],
88-
**attributes: Any,
81+
invocation: LLMInvocation,
8982
) -> LLMInvocation:
90-
"""Start an LLM invocation and create a pending span entry.
91-
92-
Known attributes provided via ``**attributes`` (``provider``,
93-
``response_model_name``, ``response_id``, ``input_tokens``,
94-
``output_tokens``) are extracted and set as explicit fields on the
95-
``LLMInvocation``. Any remaining keys are preserved in
96-
``invocation.attributes`` for custom metadata.
97-
98-
Returns the ``LLMInvocation`` to use with `stop_llm` and `fail_llm`.
99-
"""
100-
invocation = LLMInvocation(
101-
request_model=request_model,
102-
input_messages=input_messages,
103-
attributes=attributes,
104-
)
105-
_apply_known_attrs_to_invocation(invocation, invocation.attributes)
83+
"""Start an LLM invocation and create a pending span entry."""
10684
self._generator.start(invocation)
10785
return invocation
10886

109-
def stop_llm(
110-
self,
111-
invocation: LLMInvocation,
112-
output_messages: List[OutputMessage],
113-
**attributes: Any,
114-
) -> LLMInvocation:
87+
def stop_llm(self, invocation: LLMInvocation) -> LLMInvocation:
11588
"""Finalize an LLM invocation successfully and end its span."""
11689
invocation.end_time = time.time()
117-
invocation.output_messages = output_messages
118-
_apply_known_attrs_to_invocation(invocation, attributes)
119-
invocation.attributes.update(attributes)
12090
self._generator.finish(invocation)
12191
return invocation
12292

12393
def fail_llm(
124-
self, invocation: LLMInvocation, error: Error, **attributes: Any
94+
self, invocation: LLMInvocation, error: Error
12595
) -> LLMInvocation:
12696
"""Fail an LLM invocation and end its span with error status."""
12797
invocation.end_time = time.time()
128-
_apply_known_attrs_to_invocation(invocation, attributes)
129-
invocation.attributes.update(**attributes)
13098
self._generator.error(error, invocation)
13199
return invocation
132100

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

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414

1515
import json
1616
from dataclasses import asdict
17-
from typing import List
17+
from typing import Any, Dict, List
1818

1919
from opentelemetry.semconv._incubating.attributes import (
2020
gen_ai_attributes as GenAI,
@@ -100,12 +100,21 @@ def _maybe_set_span_messages(
100100
)
101101

102102

103+
def _maybe_set_span_extra_attributes(
104+
span: Span,
105+
attributes: Dict[str, Any],
106+
) -> None:
107+
for key, value in attributes.items():
108+
span.set_attribute(key, value)
109+
110+
103111
def _apply_finish_attributes(span: Span, invocation: LLMInvocation) -> None:
104112
"""Apply attributes/messages common to finish() paths."""
105113
_apply_common_span_attributes(span, invocation)
106114
_maybe_set_span_messages(
107115
span, invocation.input_messages, invocation.output_messages
108116
)
117+
_maybe_set_span_extra_attributes(span, invocation.attributes)
109118

110119

111120
def _apply_error_attributes(span: Span, error: Error) -> None:

util/opentelemetry-util-genai/tests/test_utils.py

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

15+
import json
1516
import os
1617
import unittest
1718
from unittest.mock import patch
18-
from uuid import uuid4
1919

2020
from opentelemetry import trace
2121
from opentelemetry.instrumentation._semconv import (
@@ -34,6 +34,7 @@
3434
from opentelemetry.util.genai.types import (
3535
ContentCapturingMode,
3636
InputMessage,
37+
LLMInvocation,
3738
OutputMessage,
3839
Text,
3940
)
@@ -130,17 +131,18 @@ def test_llm_start_and_stop_creates_span(self): # pylint: disable=no-self-use
130131
)
131132

132133
# Start and stop LLM invocation
133-
invocation = self.telemetry_handler.start_llm(
134+
invocation = LLMInvocation(
134135
request_model="test-model",
135136
input_messages=[message],
136-
custom_attr="value",
137137
provider="test-provider",
138+
attributes={"custom_attr": "value"},
138139
)
139-
self.telemetry_handler.stop_llm(
140-
invocation,
141-
output_messages=[chat_generation],
142-
extra="info",
143-
)
140+
141+
self.telemetry_handler.start_llm(invocation)
142+
assert invocation.span is not None
143+
invocation.output_messages = [chat_generation]
144+
invocation.attributes.update({"extra": "info"})
145+
self.telemetry_handler.stop_llm(invocation)
144146

145147
# Get the spans that were created
146148
spans = self.span_exporter.get_finished_spans()
@@ -165,44 +167,54 @@ def test_llm_start_and_stop_creates_span(self): # pylint: disable=no-self-use
165167
output_messages_json = span_attrs.get("gen_ai.output.messages")
166168
assert input_messages_json is not None
167169
assert output_messages_json is not None
168-
169170
assert isinstance(input_messages_json, str)
170171
assert isinstance(output_messages_json, str)
172+
input_messages = json.loads(input_messages_json)
173+
output_messages = json.loads(output_messages_json)
174+
assert len(input_messages) == 1
175+
assert len(output_messages) == 1
176+
assert input_messages[0].get("role") == "Human"
177+
assert output_messages[0].get("role") == "AI"
178+
assert output_messages[0].get("finish_reason") == "stop"
179+
assert (
180+
output_messages[0].get("parts")[0].get("content") == "hello back"
181+
)
182+
183+
# Check that extra attributes are added to the span
184+
assert span_attrs.get("extra") == "info"
185+
assert span_attrs.get("custom_attr") == "value"
171186

172187
@patch_env_vars(
173188
stability_mode="gen_ai_latest_experimental",
174189
content_capturing="SPAN_ONLY",
175190
)
176191
def test_parent_child_span_relationship(self):
177-
parent_id = uuid4()
178-
child_id = uuid4()
179192
message = InputMessage(role="Human", parts=[Text(content="hi")])
180193
chat_generation = OutputMessage(
181194
role="AI", parts=[Text(content="ok")], finish_reason="stop"
182195
)
183196

184197
# Start parent and child (child references parent_run_id)
185-
parent_invocation = self.telemetry_handler.start_llm(
198+
parent_invocation = LLMInvocation(
186199
request_model="parent-model",
187200
input_messages=[message],
188-
run_id=parent_id,
189201
provider="test-provider",
190202
)
191-
child_invocation = self.telemetry_handler.start_llm(
203+
child_invocation = LLMInvocation(
192204
request_model="child-model",
193205
input_messages=[message],
194-
run_id=child_id,
195-
parent_run_id=parent_id,
196206
provider="test-provider",
197207
)
198208

209+
# Pass invocation data to start_llm
210+
self.telemetry_handler.start_llm(parent_invocation)
211+
self.telemetry_handler.start_llm(child_invocation)
212+
199213
# Stop child first, then parent (order should not matter)
200-
self.telemetry_handler.stop_llm(
201-
child_invocation, output_messages=[chat_generation]
202-
)
203-
self.telemetry_handler.stop_llm(
204-
parent_invocation, output_messages=[chat_generation]
205-
)
214+
child_invocation.output_messages = [chat_generation]
215+
parent_invocation.output_messages = [chat_generation]
216+
self.telemetry_handler.stop_llm(child_invocation)
217+
self.telemetry_handler.stop_llm(parent_invocation)
206218

207219
spans = self.span_exporter.get_finished_spans()
208220
assert len(spans) == 2

0 commit comments

Comments
 (0)