Skip to content

Commit 226e4c9

Browse files
Merge branch 'main' into wrap-connect-kwargs
2 parents 362bd17 + 96a9d0f commit 226e4c9

File tree

11 files changed

+454
-134
lines changed

11 files changed

+454
-134
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
5252
([#3842](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3842))
5353
- `opentelemetry-instrumentation-mysqlclient`: Pass all keyword parameters
5454
([#3950](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3950))
55+
- `opentelemetry-instrumentation-redis`: Add default span name for pipeline operations
56+
([#3941](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3941))
57+
- `opentelemetry-instrumentation-pymongo`: Fix invalid mongodb collection attribute type
58+
([#3942](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3942))
5559

5660
## Version 1.38.0/0.59b0 (2025-10-16)
5761

instrumentation/opentelemetry-instrumentation-pymongo/src/opentelemetry/instrumentation/pymongo/__init__.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,7 @@ def started(self, event: monitoring.CommandStartedEvent):
138138
command_name = event.command_name
139139
span_name = f"{event.database_name}.{command_name}"
140140
statement = self._get_statement_by_command_name(command_name, event)
141-
collection = event.command.get(event.command_name)
141+
collection = _get_command_collection_name(event)
142142

143143
try:
144144
span = self._tracer.start_span(span_name, kind=SpanKind.CLIENT)
@@ -226,6 +226,13 @@ def _get_statement_by_command_name(
226226
return statement
227227

228228

229+
def _get_command_collection_name(event: CommandEvent) -> str | None:
230+
collection_name = event.command.get(event.command_name)
231+
if not collection_name or not isinstance(collection_name, str):
232+
return None
233+
return collection_name
234+
235+
229236
def _get_span_dict_key(
230237
event: CommandEvent,
231238
) -> int | tuple[int, tuple[str, int | None]]:

instrumentation/opentelemetry-instrumentation-pymongo/tests/test_pymongo.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -278,6 +278,43 @@ def test_capture_statement_disabled_aggregate(self):
278278
span.attributes[SpanAttributes.DB_STATEMENT], "aggregate"
279279
)
280280

281+
def test_collection_name_attribute(self):
282+
scenarios = [
283+
(
284+
{
285+
"command_name": "find",
286+
"find": "test_collection",
287+
},
288+
"test_collection",
289+
),
290+
({"command_name": "find"}, None),
291+
({"command_name": "find", "find": b"invalid"}, None),
292+
]
293+
for command_attrs, expected in scenarios:
294+
with self.subTest(command_attrs=command_attrs, expected=expected):
295+
mock_event = MockEvent(command_attrs)
296+
297+
command_tracer = CommandTracer(
298+
self.tracer, capture_statement=True
299+
)
300+
command_tracer.started(event=mock_event)
301+
command_tracer.succeeded(event=mock_event)
302+
303+
spans_list = self.memory_exporter.get_finished_spans()
304+
305+
self.assertEqual(len(spans_list), 1)
306+
span = spans_list[0]
307+
308+
self.assertEqual(
309+
span.attributes[SpanAttributes.DB_STATEMENT], "find"
310+
)
311+
312+
self.assertEqual(
313+
span.attributes.get(SpanAttributes.DB_MONGODB_COLLECTION),
314+
expected,
315+
)
316+
self.memory_exporter.clear()
317+
281318

282319
class MockCommand:
283320
def __init__(self, command_attrs):

instrumentation/opentelemetry-instrumentation-redis/src/opentelemetry/instrumentation/redis/util.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -207,4 +207,4 @@ def _build_span_meta_data_for_pipeline(
207207
resource = ""
208208
span_name = ""
209209

210-
return command_stack, resource, span_name
210+
return command_stack, resource, span_name or "redis"

instrumentation/opentelemetry-instrumentation-redis/tests/test_redis.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -390,6 +390,17 @@ def redis_operations():
390390
self.assertEqual(span.kind, SpanKind.CLIENT)
391391
self.assertEqual(span.status.status_code, trace.StatusCode.UNSET)
392392

393+
def test_span_name_empty_pipeline(self):
394+
redis_client = fakeredis.FakeStrictRedis()
395+
pipe = redis_client.pipeline()
396+
pipe.execute()
397+
398+
spans = self.memory_exporter.get_finished_spans()
399+
self.assertEqual(len(spans), 1)
400+
self.assertEqual(spans[0].name, "redis")
401+
self.assertEqual(spans[0].kind, SpanKind.CLIENT)
402+
self.assertEqual(spans[0].status.status_code, trace.StatusCode.UNSET)
403+
393404

394405
class TestRedisAsync(TestBase, IsolatedAsyncioTestCase):
395406
def assert_span_count(self, count: int):
@@ -543,6 +554,22 @@ def response_hook(span, conn, args):
543554
await self.client.set("key", "value")
544555
spans = self.assert_span_count(0)
545556

557+
@pytest.mark.asyncio
558+
async def test_span_name_empty_pipeline(self):
559+
redis_client = fakeredis.aioredis.FakeRedis()
560+
self.instrumentor.instrument_client(
561+
client=redis_client, tracer_provider=self.tracer_provider
562+
)
563+
async with redis_client.pipeline() as pipe:
564+
await pipe.execute()
565+
566+
spans = self.memory_exporter.get_finished_spans()
567+
self.assertEqual(len(spans), 1)
568+
self.assertEqual(spans[0].name, "redis")
569+
self.assertEqual(spans[0].kind, SpanKind.CLIENT)
570+
self.assertEqual(spans[0].status.status_code, trace.StatusCode.UNSET)
571+
self.instrumentor.uninstrument_client(client=redis_client)
572+
546573

547574
class TestRedisInstance(TestBase):
548575
def assert_span_count(self, count: int):

util/opentelemetry-util-genai/CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1010
- Minor change to check LRU cache in Completion Hook before acquiring semaphore/thread ([#3907](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3907)).
1111
- Add environment variable for genai upload hook queue size
1212
([https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3943](#3943))
13+
- Add more Semconv attributes to LLMInvocation spans.
14+
([https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3862](#3862))
1315

1416
## Version 0.2b0 (2025-10-14)
1517

util/opentelemetry-util-genai/pyproject.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,8 @@ classifiers = [
2525
"Programming Language :: Python :: 3.13",
2626
]
2727
dependencies = [
28-
"opentelemetry-instrumentation ~= 0.57b0",
29-
"opentelemetry-semantic-conventions ~= 0.57b0",
28+
"opentelemetry-instrumentation ~= 0.58b0",
29+
"opentelemetry-semantic-conventions ~= 0.58b0",
3030
"opentelemetry-api>=1.31.0",
3131
]
3232

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

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@
6161
from __future__ import annotations
6262

6363
from contextlib import contextmanager
64-
from typing import Iterator, Optional
64+
from typing import Iterator
6565

6666
from opentelemetry import context as otel_context
6767
from opentelemetry.semconv._incubating.attributes import (
@@ -93,7 +93,7 @@ def __init__(self, tracer_provider: TracerProvider | None = None):
9393
__name__,
9494
__version__,
9595
tracer_provider,
96-
schema_url=Schemas.V1_36_0.value,
96+
schema_url=Schemas.V1_37_0.value,
9797
)
9898

9999
def start_llm(
@@ -132,6 +132,7 @@ def fail_llm( # pylint: disable=no-self-use
132132
# TODO: Provide feedback that this invocation was not started
133133
return invocation
134134

135+
_apply_finish_attributes(invocation.span, invocation)
135136
_apply_error_attributes(invocation.span, error)
136137
# Detach context and end span
137138
otel_context.detach(invocation.context_token)
@@ -140,7 +141,7 @@ def fail_llm( # pylint: disable=no-self-use
140141

141142
@contextmanager
142143
def llm(
143-
self, invocation: Optional[LLMInvocation] = None
144+
self, invocation: LLMInvocation | None = None
144145
) -> Iterator[LLMInvocation]:
145146
"""Context manager for LLM invocations.
146147
@@ -169,7 +170,7 @@ def get_telemetry_handler(
169170
"""
170171
Returns a singleton TelemetryHandler instance.
171172
"""
172-
handler: Optional[TelemetryHandler] = getattr(
173+
handler: TelemetryHandler | None = getattr(
173174
get_telemetry_handler, "_default_handler", None
174175
)
175176
if handler is None:

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

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

15+
from __future__ import annotations
16+
1517
from dataclasses import asdict
16-
from typing import List
18+
from typing import Any
1719

1820
from opentelemetry.semconv._incubating.attributes import (
1921
gen_ai_attributes as GenAI,
@@ -60,32 +62,13 @@ def _apply_common_span_attributes(
6062
# TODO: clean provider name to match GenAiProviderNameValues?
6163
span.set_attribute(GenAI.GEN_AI_PROVIDER_NAME, invocation.provider)
6264

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-
)
65+
_apply_response_attributes(span, invocation)
8366

8467

8568
def _maybe_set_span_messages(
8669
span: Span,
87-
input_messages: List[InputMessage],
88-
output_messages: List[OutputMessage],
70+
input_messages: list[InputMessage],
71+
output_messages: list[OutputMessage],
8972
) -> None:
9073
if not is_experimental_mode() or get_content_capturing_mode() not in (
9174
ContentCapturingMode.SPAN_ONLY,
@@ -112,6 +95,8 @@ def _apply_finish_attributes(span: Span, invocation: LLMInvocation) -> None:
11295
_maybe_set_span_messages(
11396
span, invocation.input_messages, invocation.output_messages
11497
)
98+
_apply_request_attributes(span, invocation)
99+
_apply_response_attributes(span, invocation)
115100
span.set_attributes(invocation.attributes)
116101

117102

@@ -122,7 +107,75 @@ def _apply_error_attributes(span: Span, error: Error) -> None:
122107
span.set_attribute(ErrorAttributes.ERROR_TYPE, error.type.__qualname__)
123108

124109

110+
def _apply_request_attributes(span: Span, invocation: LLMInvocation) -> None:
111+
"""Attach GenAI request semantic convention attributes to the span."""
112+
attributes: dict[str, Any] = {}
113+
if invocation.temperature is not None:
114+
attributes[GenAI.GEN_AI_REQUEST_TEMPERATURE] = invocation.temperature
115+
if invocation.top_p is not None:
116+
attributes[GenAI.GEN_AI_REQUEST_TOP_P] = invocation.top_p
117+
if invocation.frequency_penalty is not None:
118+
attributes[GenAI.GEN_AI_REQUEST_FREQUENCY_PENALTY] = (
119+
invocation.frequency_penalty
120+
)
121+
if invocation.presence_penalty is not None:
122+
attributes[GenAI.GEN_AI_REQUEST_PRESENCE_PENALTY] = (
123+
invocation.presence_penalty
124+
)
125+
if invocation.max_tokens is not None:
126+
attributes[GenAI.GEN_AI_REQUEST_MAX_TOKENS] = invocation.max_tokens
127+
if invocation.stop_sequences is not None:
128+
attributes[GenAI.GEN_AI_REQUEST_STOP_SEQUENCES] = (
129+
invocation.stop_sequences
130+
)
131+
if invocation.seed is not None:
132+
attributes[GenAI.GEN_AI_REQUEST_SEED] = invocation.seed
133+
if attributes:
134+
span.set_attributes(attributes)
135+
136+
137+
def _apply_response_attributes(span: Span, invocation: LLMInvocation) -> None:
138+
"""Attach GenAI response semantic convention attributes to the span."""
139+
attributes: dict[str, Any] = {}
140+
141+
finish_reasons: list[str] | None
142+
if invocation.finish_reasons is not None:
143+
finish_reasons = invocation.finish_reasons
144+
elif invocation.output_messages:
145+
finish_reasons = [
146+
message.finish_reason
147+
for message in invocation.output_messages
148+
if message.finish_reason
149+
]
150+
else:
151+
finish_reasons = None
152+
153+
if finish_reasons:
154+
# De-duplicate finish reasons
155+
unique_finish_reasons = sorted(set(finish_reasons))
156+
if unique_finish_reasons:
157+
attributes[GenAI.GEN_AI_RESPONSE_FINISH_REASONS] = (
158+
unique_finish_reasons
159+
)
160+
161+
if invocation.response_model_name is not None:
162+
attributes[GenAI.GEN_AI_RESPONSE_MODEL] = (
163+
invocation.response_model_name
164+
)
165+
if invocation.response_id is not None:
166+
attributes[GenAI.GEN_AI_RESPONSE_ID] = invocation.response_id
167+
if invocation.input_tokens is not None:
168+
attributes[GenAI.GEN_AI_USAGE_INPUT_TOKENS] = invocation.input_tokens
169+
if invocation.output_tokens is not None:
170+
attributes[GenAI.GEN_AI_USAGE_OUTPUT_TOKENS] = invocation.output_tokens
171+
172+
if attributes:
173+
span.set_attributes(attributes)
174+
175+
125176
__all__ = [
126177
"_apply_finish_attributes",
127178
"_apply_error_attributes",
179+
"_apply_request_attributes",
180+
"_apply_response_attributes",
128181
]

0 commit comments

Comments
 (0)