Skip to content

Commit a8ccaa9

Browse files
authored
Merge branch 'main' into ci-concurrency
2 parents 8ec96bd + b8018c5 commit a8ccaa9

File tree

10 files changed

+387
-8
lines changed

10 files changed

+387
-8
lines changed

CHANGELOG.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1111
1212
## Unreleased
1313

14+
- `opentelemetry-instrumentation-asyncio` Fix duplicate instrumentation.
15+
([[#3383](https://github.com/open-telemetry/opentelemetry-python-contrib/issues/3383)])
16+
17+
### Added
18+
19+
### Fixed
20+
21+
- `opentelemetry-instrumentation` Catch `ModuleNotFoundError` when the library is not installed
22+
and log as debug instead of exception
23+
([#3423](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3423))
24+
1425
## Version 1.32.0/0.53b0 (2025-04-10)
1526

1627
### Added
@@ -31,9 +42,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
3142
([#3113](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3113))
3243
- `opentelemetry-instrumentation-grpc` Fix error when using gprc versions <= 1.50.0 with unix sockets.
3344
([[#3393](https://github.com/open-telemetry/opentelemetry-python-contrib/issues/3393)])
45+
- `opentelemetry-instrumentation-asyncio` Fix duplicate instrumentation.
46+
([[#3383](https://github.com/open-telemetry/opentelemetry-python-contrib/issues/3383)])
3447
- `opentelemetry-instrumentation-aiokafka` Fix send_and_wait method no headers kwargs error.
3548
([[#3332](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3332)])
3649

50+
3751
## Version 1.31.0/0.52b0 (2025-03-12)
3852

3953
### Added

instrumentation-genai/opentelemetry-instrumentation-google-genai/CHANGELOG.md

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

1010
- Restructure tests to keep in line with repository conventions ([#3344](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3344))
1111

12+
- Fix [bug](https://github.com/open-telemetry/opentelemetry-python-contrib/issues/3416) where
13+
span attribute `gen_ai.response.finish_reasons` is empty ([#3417](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3417))
14+
1215
## Version 0.1b0 (2025-03-05)
1316

1417
- Add support for async and streaming.

instrumentation-genai/opentelemetry-instrumentation-google-genai/src/opentelemetry/instrumentation/google_genai/generate_content.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,7 @@ def process_response(self, response: GenerateContentResponse):
252252
# need to be reflected back into the span attributes.
253253
#
254254
# See also: TODOS.md.
255+
self._update_finish_reasons(response)
255256
self._maybe_update_token_counts(response)
256257
self._maybe_update_error_type(response)
257258
self._maybe_log_response(response)
@@ -275,6 +276,18 @@ def finalize_processing(self):
275276
self._record_token_usage_metric()
276277
self._record_duration_metric()
277278

279+
def _update_finish_reasons(self, response):
280+
if not response.candidates:
281+
return
282+
for candidate in response.candidates:
283+
finish_reason = candidate.finish_reason
284+
if finish_reason is None:
285+
continue
286+
finish_reason_str = finish_reason.name.lower().removeprefix(
287+
"finish_reason_"
288+
)
289+
self._finish_reasons_set.add(finish_reason_str)
290+
278291
def _maybe_update_token_counts(self, response: GenerateContentResponse):
279292
input_tokens = _get_response_property(
280293
response, "usage_metadata.prompt_token_count"
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
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+
from google.genai import types as genai_types
17+
18+
from .base import TestCase
19+
20+
21+
class FinishReasonsTestCase(TestCase):
22+
def generate_and_get_span_finish_reasons(self):
23+
self.client.models.generate_content(
24+
model="gemini-2.5-flash-001", contents="Some prompt"
25+
)
26+
span = self.otel.get_span_named(
27+
"generate_content gemini-2.5-flash-001"
28+
)
29+
assert span is not None
30+
assert "gen_ai.response.finish_reasons" in span.attributes
31+
return list(span.attributes["gen_ai.response.finish_reasons"])
32+
33+
def test_single_candidate_with_valid_reason(self):
34+
self.configure_valid_response(
35+
candidate=genai_types.Candidate(
36+
finish_reason=genai_types.FinishReason.STOP
37+
)
38+
)
39+
self.assertEqual(self.generate_and_get_span_finish_reasons(), ["stop"])
40+
41+
def test_single_candidate_with_safety_reason(self):
42+
self.configure_valid_response(
43+
candidate=genai_types.Candidate(
44+
finish_reason=genai_types.FinishReason.SAFETY
45+
)
46+
)
47+
self.assertEqual(
48+
self.generate_and_get_span_finish_reasons(), ["safety"]
49+
)
50+
51+
def test_single_candidate_with_max_tokens_reason(self):
52+
self.configure_valid_response(
53+
candidate=genai_types.Candidate(
54+
finish_reason=genai_types.FinishReason.MAX_TOKENS
55+
)
56+
)
57+
self.assertEqual(
58+
self.generate_and_get_span_finish_reasons(), ["max_tokens"]
59+
)
60+
61+
def test_single_candidate_with_no_reason(self):
62+
self.configure_valid_response(
63+
candidate=genai_types.Candidate(finish_reason=None)
64+
)
65+
self.assertEqual(self.generate_and_get_span_finish_reasons(), [])
66+
67+
def test_single_candidate_with_unspecified_reason(self):
68+
self.configure_valid_response(
69+
candidate=genai_types.Candidate(
70+
finish_reason=genai_types.FinishReason.FINISH_REASON_UNSPECIFIED
71+
)
72+
)
73+
self.assertEqual(
74+
self.generate_and_get_span_finish_reasons(), ["unspecified"]
75+
)
76+
77+
def test_multiple_candidates_with_valid_reasons(self):
78+
self.configure_valid_response(
79+
candidates=[
80+
genai_types.Candidate(
81+
finish_reason=genai_types.FinishReason.MAX_TOKENS
82+
),
83+
genai_types.Candidate(
84+
finish_reason=genai_types.FinishReason.STOP
85+
),
86+
]
87+
)
88+
self.assertEqual(
89+
self.generate_and_get_span_finish_reasons(), ["max_tokens", "stop"]
90+
)
91+
92+
def test_sorts_finish_reasons(self):
93+
self.configure_valid_response(
94+
candidates=[
95+
genai_types.Candidate(
96+
finish_reason=genai_types.FinishReason.STOP
97+
),
98+
genai_types.Candidate(
99+
finish_reason=genai_types.FinishReason.MAX_TOKENS
100+
),
101+
genai_types.Candidate(
102+
finish_reason=genai_types.FinishReason.SAFETY
103+
),
104+
]
105+
)
106+
self.assertEqual(
107+
self.generate_and_get_span_finish_reasons(),
108+
["max_tokens", "safety", "stop"],
109+
)
110+
111+
def test_deduplicates_finish_reasons(self):
112+
self.configure_valid_response(
113+
candidates=[
114+
genai_types.Candidate(
115+
finish_reason=genai_types.FinishReason.STOP
116+
),
117+
genai_types.Candidate(
118+
finish_reason=genai_types.FinishReason.MAX_TOKENS
119+
),
120+
genai_types.Candidate(
121+
finish_reason=genai_types.FinishReason.STOP
122+
),
123+
genai_types.Candidate(
124+
finish_reason=genai_types.FinishReason.STOP
125+
),
126+
genai_types.Candidate(
127+
finish_reason=genai_types.FinishReason.SAFETY
128+
),
129+
genai_types.Candidate(
130+
finish_reason=genai_types.FinishReason.STOP
131+
),
132+
genai_types.Candidate(
133+
finish_reason=genai_types.FinishReason.STOP
134+
),
135+
genai_types.Candidate(
136+
finish_reason=genai_types.FinishReason.STOP
137+
),
138+
]
139+
)
140+
self.assertEqual(
141+
self.generate_and_get_span_finish_reasons(),
142+
["max_tokens", "safety", "stop"],
143+
)

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

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,9 @@ def func():
9393

9494
from wrapt import wrap_function_wrapper as _wrap
9595

96+
from opentelemetry.instrumentation.asyncio.instrumentation_state import (
97+
_is_instrumented,
98+
)
9699
from opentelemetry.instrumentation.asyncio.package import _instruments
97100
from opentelemetry.instrumentation.asyncio.utils import (
98101
get_coros_to_trace,
@@ -237,7 +240,12 @@ def wrap_taskgroup_create_task(method, instance, args, kwargs) -> None:
237240
)
238241

239242
def trace_to_thread(self, func: callable):
240-
"""Trace a function."""
243+
"""
244+
Trace a function, but if already instrumented, skip double-wrapping.
245+
"""
246+
if _is_instrumented(func):
247+
return func
248+
241249
start = default_timer()
242250
func_name = getattr(func, "__name__", None)
243251
if func_name is None and isinstance(func, functools.partial):
@@ -270,6 +278,13 @@ def trace_item(self, coro_or_future):
270278
return coro_or_future
271279

272280
async def trace_coroutine(self, coro):
281+
"""
282+
Wrap a coroutine so that we measure its duration, metrics, etc.
283+
If already instrumented, simply 'await coro' to preserve call behavior.
284+
"""
285+
if _is_instrumented(coro):
286+
return await coro
287+
273288
if not hasattr(coro, "__name__"):
274289
return await coro
275290
start = default_timer()
@@ -303,6 +318,12 @@ async def trace_coroutine(self, coro):
303318
self.record_process(start, attr, span, exception)
304319

305320
def trace_future(self, future):
321+
"""
322+
Wrap a Future's done callback. If already instrumented, skip re-wrapping.
323+
"""
324+
if _is_instrumented(future):
325+
return future
326+
306327
start = default_timer()
307328
span = (
308329
self._tracer.start_span(f"{ASYNCIO_PREFIX} future")
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
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+
Instrumentation State Tracker
17+
18+
This module provides helper functions to safely track whether a coroutine,
19+
Future, or function has already been instrumented by the OpenTelemetry
20+
asyncio instrumentation layer.
21+
22+
Some Python objects (like coroutines or functions) may not support setting
23+
custom attributes or weak references. To avoid memory leaks and runtime
24+
errors, this module uses a WeakKeyDictionary to safely track instrumented
25+
objects.
26+
27+
If an object cannot be weak-referenced, it is silently skipped.
28+
29+
Usage:
30+
if not _is_instrumented(obj):
31+
_mark_instrumented(obj)
32+
# instrument the object...
33+
"""
34+
35+
import weakref
36+
from typing import Any
37+
38+
# A global WeakSet to track instrumented objects.
39+
# Entries are automatically removed when the objects are garbage collected.
40+
_instrumented_tasks = weakref.WeakSet()
41+
42+
43+
def _is_instrumented(obj: Any) -> bool:
44+
"""
45+
Check whether the object has already been instrumented.
46+
If not, mark it as instrumented (only if weakref is supported).
47+
48+
Args:
49+
obj: A coroutine, function, or Future.
50+
51+
Returns:
52+
True if the object was already instrumented.
53+
False if the object is not trackable (no weakref support), or just marked now.
54+
55+
Note:
56+
In Python 3.12+, some internal types like `async_generator_asend`
57+
raise TypeError when weakref is attempted.
58+
"""
59+
try:
60+
if obj in _instrumented_tasks:
61+
return True
62+
_instrumented_tasks.add(obj)
63+
return False
64+
except TypeError:
65+
# Object doesn't support weak references → can't track instrumentation
66+
return False

0 commit comments

Comments
 (0)