Skip to content

Commit 541639b

Browse files
committed
GenAI utils: Add server address, port and additional metric attributes to LLM invocation (open-telemetry#4069)
* Add server address, port and additional metric attributes to LLM invocation * changelog * harden tests * lint * review * up * comments
1 parent bd104b0 commit 541639b

7 files changed

Lines changed: 117 additions & 14 deletions

File tree

instrumentation-genai/opentelemetry-instrumentation-langchain/src/opentelemetry/instrumentation/langchain/span_manager.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
gen_ai_attributes as GenAI,
2121
)
2222
from opentelemetry.semconv.attributes import (
23-
error_attributes as ErrorAttributes,
23+
error_attributes,
2424
)
2525
from opentelemetry.trace import Span, SpanKind, Tracer, set_span_in_context
2626
from opentelemetry.trace.status import Status, StatusCode
@@ -112,6 +112,6 @@ def handle_error(self, error: BaseException, run_id: UUID):
112112
return
113113
span.set_status(Status(StatusCode.ERROR, str(error)))
114114
span.set_attribute(
115-
ErrorAttributes.ERROR_TYPE, type(error).__qualname__
115+
error_attributes.ERROR_TYPE, type(error).__qualname__
116116
)
117117
self.end_span(run_id)

util/opentelemetry-util-genai/CHANGELOG.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## Unreleased
99

10+
- Add support for `server.address`, `server.port` on all signals and additional metric-only attributes
11+
([#4069](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/4069))
1012
- Log error when `fsspec` fails to be imported instead of silently failing ([#4037](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/4037)).
1113
- Minor change to check LRU cache in Completion Hook before acquiring semaphore/thread ([#3907](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3907)).
1214
- Add environment variable for genai upload hook queue size
1315
([https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3943](#3943))
14-
- Add more Semconv attributes to LLMInvocation spans.
16+
- Add more Semconv attributes to LLMInvocation spans.
1517
([https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3862](#3862))
1618
- Limit the upload hook thread pool to 64 workers
1719
([https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3944](#3944))

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

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,16 @@
33
from __future__ import annotations
44

55
import timeit
6-
from numbers import Number
76
from typing import Dict, Optional
87

98
from opentelemetry.metrics import Histogram, Meter
109
from opentelemetry.semconv._incubating.attributes import (
1110
gen_ai_attributes as GenAI,
1211
)
12+
from opentelemetry.semconv.attributes import (
13+
error_attributes,
14+
server_attributes,
15+
)
1316
from opentelemetry.trace import Span, set_span_in_context
1417
from opentelemetry.util.genai.instruments import (
1518
create_duration_histogram,
@@ -34,6 +37,9 @@ def record(
3437
error_type: Optional[str] = None,
3538
) -> None:
3639
"""Record duration and token metrics for an invocation if possible."""
40+
41+
# pylint: disable=too-many-branches
42+
3743
if span is None:
3844
return
3945

@@ -64,6 +70,14 @@ def record(
6470
attributes[GenAI.GEN_AI_RESPONSE_MODEL] = (
6571
invocation.response_model_name
6672
)
73+
if invocation.server_address:
74+
attributes[server_attributes.SERVER_ADDRESS] = (
75+
invocation.server_address
76+
)
77+
if invocation.server_port is not None:
78+
attributes[server_attributes.SERVER_PORT] = invocation.server_port
79+
if invocation.metric_attributes:
80+
attributes.update(invocation.metric_attributes)
6781

6882
# Calculate duration from span timing or invocation monotonic start
6983
duration_seconds: Optional[float] = None
@@ -75,13 +89,9 @@ def record(
7589

7690
span_context = set_span_in_context(span)
7791
if error_type:
78-
attributes["error.type"] = error_type
92+
attributes[error_attributes.ERROR_TYPE] = error_type
7993

80-
if (
81-
duration_seconds is not None
82-
and isinstance(duration_seconds, Number)
83-
and duration_seconds >= 0
84-
):
94+
if duration_seconds is not None:
8595
self._duration_histogram.record(
8696
duration_seconds,
8797
attributes=attributes,

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

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,8 @@
2121
gen_ai_attributes as GenAI,
2222
)
2323
from opentelemetry.semconv.attributes import (
24-
error_attributes as ErrorAttributes,
24+
error_attributes,
25+
server_attributes,
2526
)
2627
from opentelemetry.trace import (
2728
Span,
@@ -62,6 +63,15 @@ def _apply_common_span_attributes(
6263
# TODO: clean provider name to match GenAiProviderNameValues?
6364
span.set_attribute(GenAI.GEN_AI_PROVIDER_NAME, invocation.provider)
6465

66+
if invocation.server_address:
67+
span.set_attribute(
68+
server_attributes.SERVER_ADDRESS, invocation.server_address
69+
)
70+
if invocation.server_port:
71+
span.set_attribute(
72+
server_attributes.SERVER_PORT, invocation.server_port
73+
)
74+
6575
_apply_response_attributes(span, invocation)
6676

6777

@@ -104,7 +114,9 @@ def _apply_error_attributes(span: Span, error: Error) -> None:
104114
"""Apply status and error attributes common to error() paths."""
105115
span.set_status(Status(StatusCode.ERROR, error.message))
106116
if span.is_recording():
107-
span.set_attribute(ErrorAttributes.ERROR_TYPE, error.type.__qualname__)
117+
span.set_attribute(
118+
error_attributes.ERROR_TYPE, error.type.__qualname__
119+
)
108120

109121

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

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

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,13 +116,26 @@ class LLMInvocation:
116116
input_tokens: int | None = None
117117
output_tokens: int | None = None
118118
attributes: dict[str, Any] = field(default_factory=_new_str_any_dict)
119+
"""
120+
Additional attributes to set on spans and/or events. These attributes
121+
will not be set on metrics.
122+
"""
123+
metric_attributes: dict[str, Any] = field(
124+
default_factory=_new_str_any_dict
125+
)
126+
"""
127+
Additional attributes to set on metrics. Must be of a low cardinality.
128+
These attributes will not be set on spans or events.
129+
"""
119130
temperature: float | None = None
120131
top_p: float | None = None
121132
frequency_penalty: float | None = None
122133
presence_penalty: float | None = None
123134
max_tokens: int | None = None
124135
stop_sequences: list[str] | None = None
125136
seed: int | None = None
137+
server_address: str | None = None
138+
server_port: int | None = None
126139
# Monotonic start time in seconds (from timeit.default_timer) used
127140
# for duration calculations to avoid mixing clock sources. This is
128141
# populated by the TelemetryHandler when starting an invocation.

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

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,43 @@ def test_stop_llm_records_duration_and_tokens(self) -> None:
8484
places=3,
8585
)
8686

87+
def test_stop_llm_records_duration_and_tokens_with_additional_attributes(
88+
self,
89+
) -> None:
90+
handler = TelemetryHandler(
91+
tracer_provider=self.tracer_provider,
92+
meter_provider=self.meter_provider,
93+
)
94+
95+
invocation = LLMInvocation(request_model="model", provider="prov")
96+
invocation.input_tokens = 5
97+
invocation.output_tokens = 7
98+
invocation.server_address = "custom.server.com"
99+
invocation.server_port = 42
100+
handler.start_llm(invocation)
101+
invocation.metric_attributes = {
102+
"custom.attribute": "custom_value",
103+
}
104+
invocation.attributes = {"should not be on metrics": "value"}
105+
handler.stop_llm(invocation)
106+
107+
metrics = self._harvest_metrics()
108+
self.assertIn("gen_ai.client.operation.duration", metrics)
109+
duration_points = metrics["gen_ai.client.operation.duration"]
110+
self.assertIn("gen_ai.client.token.usage", metrics)
111+
token_points = metrics["gen_ai.client.token.usage"]
112+
points = duration_points + token_points
113+
114+
for point in points:
115+
self.assertEqual(
116+
point.attributes["server.address"], "custom.server.com"
117+
)
118+
self.assertEqual(point.attributes["server.port"], 42)
119+
self.assertEqual(
120+
point.attributes["custom.attribute"], "custom_value"
121+
)
122+
self.assertIsNone(point.attributes.get("should not be on metrics"))
123+
87124
def test_fail_llm_records_error_and_available_tokens(self) -> None:
88125
handler = TelemetryHandler(
89126
tracer_provider=self.tracer_provider,

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

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,8 @@
3232
gen_ai_attributes as GenAI,
3333
)
3434
from opentelemetry.semconv.attributes import (
35-
error_attributes as ErrorAttributes,
35+
error_attributes,
36+
server_attributes,
3637
)
3738
from opentelemetry.semconv.schemas import Schemas
3839
from opentelemetry.trace.status import StatusCode
@@ -107,6 +108,9 @@ def _assert_span_attributes(
107108
) -> None:
108109
for key, value in expected_values.items():
109110
assert span_attrs.get(key) == value
111+
assert len(span_attrs) == len(expected_values), (
112+
f"Actual {span_attrs} are different than expected {expected_values}"
113+
)
110114

111115

112116
def _get_messages_from_attr(
@@ -215,11 +219,14 @@ def test_llm_start_and_stop_creates_span(self): # pylint: disable=no-self-use
215219
"response_id": "response-id",
216220
"input_tokens": 321,
217221
"output_tokens": 654,
222+
"server_address": "custom.server.com",
223+
"server_port": 42,
218224
}.items():
219225
setattr(invocation, attr, value)
220226
assert invocation.span is not None
221227
invocation.output_messages = [chat_generation]
222228
invocation.attributes.update({"extra": "info"})
229+
invocation.metric_attributes = {"should not be on span": "value"}
223230

224231
span = _get_single_span(self.span_exporter)
225232
self.assertEqual(span.name, "chat test-model")
@@ -231,7 +238,10 @@ def test_llm_start_and_stop_creates_span(self): # pylint: disable=no-self-use
231238
span_attrs,
232239
{
233240
GenAI.GEN_AI_OPERATION_NAME: "chat",
241+
GenAI.GEN_AI_REQUEST_MODEL: "test-model",
234242
GenAI.GEN_AI_PROVIDER_NAME: "test-provider",
243+
GenAI.GEN_AI_INPUT_MESSAGES: AnyNonNone(),
244+
GenAI.GEN_AI_OUTPUT_MESSAGES: AnyNonNone(),
235245
GenAI.GEN_AI_REQUEST_TEMPERATURE: 0.5,
236246
GenAI.GEN_AI_REQUEST_TOP_P: 0.9,
237247
GenAI.GEN_AI_REQUEST_STOP_SEQUENCES: ("stop",),
@@ -240,6 +250,8 @@ def test_llm_start_and_stop_creates_span(self): # pylint: disable=no-self-use
240250
GenAI.GEN_AI_RESPONSE_ID: "response-id",
241251
GenAI.GEN_AI_USAGE_INPUT_TOKENS: 321,
242252
GenAI.GEN_AI_USAGE_OUTPUT_TOKENS: 654,
253+
server_attributes.SERVER_ADDRESS: "custom.server.com",
254+
server_attributes.SERVER_PORT: 42,
243255
"extra": "info",
244256
"custom_attr": "value",
245257
},
@@ -286,6 +298,12 @@ def test_llm_manual_start_and_stop_creates_span(self):
286298
_assert_span_attributes(
287299
attrs,
288300
{
301+
GenAI.GEN_AI_OPERATION_NAME: "chat",
302+
GenAI.GEN_AI_REQUEST_MODEL: "manual-model",
303+
GenAI.GEN_AI_PROVIDER_NAME: "test-provider",
304+
GenAI.GEN_AI_INPUT_MESSAGES: AnyNonNone(),
305+
GenAI.GEN_AI_OUTPUT_MESSAGES: AnyNonNone(),
306+
GenAI.GEN_AI_RESPONSE_FINISH_REASONS: ("stop",),
289307
"manual": True,
290308
"extra_manual": "yes",
291309
},
@@ -312,6 +330,9 @@ def test_llm_span_finish_reasons_without_output_messages(self):
312330
_assert_span_attributes(
313331
attrs,
314332
{
333+
GenAI.GEN_AI_OPERATION_NAME: "chat",
334+
GenAI.GEN_AI_REQUEST_MODEL: "model-without-output",
335+
GenAI.GEN_AI_PROVIDER_NAME: "test-provider",
315336
GenAI.GEN_AI_RESPONSE_FINISH_REASONS: ("length",),
316337
GenAI.GEN_AI_RESPONSE_MODEL: "alt-model",
317338
GenAI.GEN_AI_RESPONSE_ID: "resp-001",
@@ -457,13 +478,21 @@ class BoomError(RuntimeError):
457478
_assert_span_attributes(
458479
span_attrs,
459480
{
460-
ErrorAttributes.ERROR_TYPE: BoomError.__qualname__,
481+
GenAI.GEN_AI_OPERATION_NAME: "chat",
482+
GenAI.GEN_AI_REQUEST_MODEL: "test-model",
483+
GenAI.GEN_AI_PROVIDER_NAME: "test-provider",
461484
GenAI.GEN_AI_REQUEST_MAX_TOKENS: 128,
462485
GenAI.GEN_AI_REQUEST_SEED: 123,
463486
GenAI.GEN_AI_RESPONSE_FINISH_REASONS: ("error",),
464487
GenAI.GEN_AI_RESPONSE_MODEL: "error-model",
465488
GenAI.GEN_AI_RESPONSE_ID: "error-response",
466489
GenAI.GEN_AI_USAGE_INPUT_TOKENS: 11,
467490
GenAI.GEN_AI_USAGE_OUTPUT_TOKENS: 22,
491+
error_attributes.ERROR_TYPE: BoomError.__qualname__,
468492
},
469493
)
494+
495+
496+
class AnyNonNone:
497+
def __eq__(self, other):
498+
return other is not None

0 commit comments

Comments
 (0)