Skip to content

Commit e72d878

Browse files
committed
Address copilot
1 parent fe4237c commit e72d878

5 files changed

Lines changed: 244 additions & 33 deletions

File tree

‎instrumentation/opentelemetry-instrumentation-genai-agno/src/opentelemetry/instrumentation/genai/agno/patch.py‎

Lines changed: 63 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,9 @@
9494
"_SUPPRESS_EMBEDDING", default=False
9595
)
9696
_patched_embedder_classes: set[type[Any]] = set()
97+
_embedder_init_subclass_saved: bool = False
98+
_had_orig_embedder_init_subclass: bool = False
99+
_orig_embedder_init_subclass: Any = None
97100

98101

99102
_KNOWN_EMBEDDERS: tuple[tuple[str, str], ...] = (
@@ -319,8 +322,30 @@ def _wrap_subclasses(base_cls: type[Any]) -> None:
319322

320323
_wrap_subclasses(Embedder)
321324

325+
global \
326+
_embedder_init_subclass_saved, \
327+
_had_orig_embedder_init_subclass, \
328+
_orig_embedder_init_subclass
329+
if not _embedder_init_subclass_saved:
330+
_had_orig_embedder_init_subclass = (
331+
"__init_subclass__" in Embedder.__dict__
332+
)
333+
_orig_embedder_init_subclass = Embedder.__dict__.get(
334+
"__init_subclass__"
335+
)
336+
_embedder_init_subclass_saved = True
337+
338+
orig_init_subclass = _orig_embedder_init_subclass
339+
322340
# Wrap new embedder classes created during runtime
323-
def _traced_init_subclass(cls: type[Any], **kwargs: Any) -> None:
341+
def _traced_init_subclass(cls: type[Embedder], **kwargs: Any) -> None:
342+
if orig_init_subclass is not None:
343+
func = getattr(
344+
orig_init_subclass, "__func__", orig_init_subclass
345+
)
346+
func(cls, **kwargs)
347+
else:
348+
super(Embedder, cls).__init_subclass__(**kwargs)
324349
if _is_instrumented:
325350
_wrap_embedder_class(cls, handler)
326351

@@ -346,8 +371,28 @@ def _safe_unwrap(target: Any, attr: str) -> None:
346371
try:
347372
from agno.knowledge.embedder.base import Embedder
348373

349-
if "__init_subclass__" in Embedder.__dict__:
350-
delattr(Embedder, "__init_subclass__")
374+
global \
375+
_embedder_init_subclass_saved, \
376+
_had_orig_embedder_init_subclass, \
377+
_orig_embedder_init_subclass
378+
if _embedder_init_subclass_saved:
379+
if (
380+
_had_orig_embedder_init_subclass
381+
and _orig_embedder_init_subclass is not None
382+
):
383+
setattr(
384+
Embedder,
385+
"__init_subclass__",
386+
_orig_embedder_init_subclass,
387+
)
388+
elif (
389+
hasattr(Embedder, "__init_subclass__")
390+
and "__init_subclass__" in Embedder.__dict__
391+
):
392+
delattr(Embedder, "__init_subclass__")
393+
_embedder_init_subclass_saved = False
394+
_orig_embedder_init_subclass = None
395+
_had_orig_embedder_init_subclass = False
351396
except Exception:
352397
pass
353398

@@ -449,6 +494,17 @@ def _extract_embedder_model(embedder: Any) -> str | None:
449494
return str(model) if model is not None else None
450495

451496

497+
def _extract_embedder_input_tokens(usage: dict[str, Any]) -> int | None:
498+
for key in ("prompt_tokens", "input_tokens", "total_tokens"):
499+
val = usage.get(key)
500+
if val is not None:
501+
try:
502+
return int(val)
503+
except (ValueError, TypeError):
504+
return None
505+
return None
506+
507+
452508
def _wrap_embedder_class(
453509
cls: type[Any],
454510
handler: TelemetryHandler,
@@ -559,16 +615,9 @@ def traced_method(
559615
pass
560616

561617
if isinstance(usage, dict):
562-
input_tokens = (
563-
usage.get("prompt_tokens")
564-
or usage.get("input_tokens")
565-
or usage.get("total_tokens")
566-
)
618+
input_tokens = _extract_embedder_input_tokens(usage)
567619
if input_tokens is not None:
568-
try:
569-
invocation.input_tokens = int(input_tokens)
570-
except (ValueError, TypeError):
571-
pass
620+
invocation.input_tokens = input_tokens
572621
if model := usage.get("model"):
573622
invocation.response_model_name = str(model)
574623

@@ -667,16 +716,9 @@ async def traced_method(
667716
pass
668717

669718
if isinstance(usage, dict):
670-
input_tokens = (
671-
usage.get("prompt_tokens")
672-
or usage.get("input_tokens")
673-
or usage.get("total_tokens")
674-
)
719+
input_tokens = _extract_embedder_input_tokens(usage)
675720
if input_tokens is not None:
676-
try:
677-
invocation.input_tokens = int(input_tokens)
678-
except (ValueError, TypeError):
679-
pass
721+
invocation.input_tokens = input_tokens
680722
if model := usage.get("model"):
681723
invocation.response_model_name = str(model)
682724

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
# Copyright The OpenTelemetry Authors
2+
# SPDX-License-Identifier: Apache-2.0
3+
4+
"""Conformance scenario: embedding generation for Agno."""
5+
6+
from __future__ import annotations
7+
8+
from dataclasses import dataclass
9+
from typing import Any
10+
11+
from agno.knowledge.embedder.base import Embedder
12+
13+
from opentelemetry.instrumentation.genai.agno import AgnoInstrumentor
14+
from opentelemetry.sdk._logs import LoggerProvider
15+
from opentelemetry.sdk.metrics import MeterProvider
16+
from opentelemetry.sdk.trace import TracerProvider
17+
from opentelemetry.test_util_genai.conformance import (
18+
ExpectedViolation,
19+
Scenario,
20+
)
21+
from opentelemetry.test_util_genai.instrumentor import instrument
22+
23+
24+
@dataclass
25+
class ConformanceEmbedder(Embedder):
26+
id: str = "text-embedding-3-small"
27+
provider: str = "openai"
28+
29+
def get_embedding_and_usage(
30+
self, text: str
31+
) -> tuple[list[float], dict[str, Any]]:
32+
return [0.1, 0.2, 0.3, 0.4], {
33+
"input_tokens": 8,
34+
"model": "text-embedding-3-small",
35+
}
36+
37+
38+
class EmbeddingScenario(Scenario):
39+
expected_spans = {"embeddings": 1}
40+
expected_metrics = (
41+
"gen_ai.client.operation.duration",
42+
"gen_ai.client.token.usage",
43+
)
44+
expected_violations = (
45+
ExpectedViolation(
46+
advice_id="genai_expected_attribute_missing",
47+
message_substring="server.address",
48+
),
49+
)
50+
51+
def run(
52+
self,
53+
*,
54+
tracer_provider: TracerProvider,
55+
meter_provider: MeterProvider,
56+
logger_provider: LoggerProvider,
57+
vcr: Any,
58+
) -> None:
59+
with instrument(
60+
AgnoInstrumentor(),
61+
tracer_provider=tracer_provider,
62+
logger_provider=logger_provider,
63+
meter_provider=meter_provider,
64+
content_capture="SPAN_ONLY",
65+
):
66+
embedder = ConformanceEmbedder()
67+
embedder.get_embedding_and_usage("conformance test embedding input")

‎instrumentation/opentelemetry-instrumentation-genai-agno/tests/test_conformance.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
)
2222

2323
from .conformance.agent import AgentScenario
24+
from .conformance.embedding import EmbeddingScenario
2425
from .conformance.retrieval import RetrievalScenario
2526
from .conformance.workflow import WorkflowScenario
2627
from .conformance.workflow_streaming import WorkflowStreamingScenario
@@ -30,6 +31,7 @@
3031
"scenario",
3132
[
3233
pytest.param(AgentScenario()),
34+
pytest.param(EmbeddingScenario()),
3335
pytest.param(RetrievalScenario()),
3436
pytest.param(WorkflowScenario()),
3537
pytest.param(WorkflowStreamingScenario()),

‎instrumentation/opentelemetry-instrumentation-genai-agno/tests/test_embedder.py‎

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
import asyncio
99
from dataclasses import dataclass
10+
from typing import Any
1011

1112
import pytest
1213
from agno.knowledge.embedder.base import Embedder
@@ -236,6 +237,21 @@ def test_embedder_error_handling(
236237
assert span.attributes.get(ERROR_TYPE) == "ValueError"
237238

238239

240+
@dataclass
241+
class ZeroUsageEmbedder(Embedder):
242+
model: str = "zero-tokens-model"
243+
244+
def get_embedding_and_usage(
245+
self, text: str
246+
) -> tuple[list[float], dict[str, int]]:
247+
return [0.1, 0.2], {"prompt_tokens": 0, "total_tokens": 12}
248+
249+
async def async_get_embedding_and_usage(
250+
self, text: str
251+
) -> tuple[list[float], dict[str, int]]:
252+
return [0.3, 0.4], {"prompt_tokens": 0, "total_tokens": 10}
253+
254+
239255
def test_embedder_uninstrument_restores_untraced_behavior(
240256
instrument_agno,
241257
span_exporter,
@@ -250,3 +266,72 @@ def test_embedder_uninstrument_restores_untraced_behavior(
250266

251267
embedder.get_embedding("untraced")
252268
assert len(span_exporter.get_finished_spans()) == 0
269+
270+
271+
def test_embedder_zero_input_tokens_usage(
272+
instrument_agno,
273+
span_exporter,
274+
) -> None:
275+
"""Test that 0 prompt_tokens is recorded as 0 and not overridden by total_tokens."""
276+
embedder = ZeroUsageEmbedder()
277+
_, usage = embedder.get_embedding_and_usage("zero text")
278+
assert usage.get("prompt_tokens") == 0
279+
280+
spans = span_exporter.get_finished_spans()
281+
assert len(spans) == 1
282+
assert spans[0].attributes.get(GEN_AI_USAGE_INPUT_TOKENS) == 0
283+
284+
span_exporter.clear()
285+
286+
async def _test() -> None:
287+
await embedder.async_get_embedding_and_usage("zero text async")
288+
289+
asyncio.run(_test())
290+
spans = span_exporter.get_finished_spans()
291+
assert len(spans) == 1
292+
assert spans[0].attributes.get(GEN_AI_USAGE_INPUT_TOKENS) == 0
293+
294+
295+
def test_embedder_init_subclass_calls_original_and_unpatch_restores(
296+
instrument_agno,
297+
span_exporter,
298+
) -> None:
299+
"""Test that custom base __init_subclass__ logic is called and restored on uninstrument."""
300+
called_with: list[tuple[type[Any], str | None]] = []
301+
302+
class TrackedBaseEmbedder(Embedder):
303+
def __init_subclass__(
304+
cls, custom_arg: str | None = None, **kwargs: Any
305+
) -> None:
306+
super().__init_subclass__(**kwargs)
307+
called_with.append((cls, custom_arg))
308+
309+
class SubTrackedEmbedder(TrackedBaseEmbedder, custom_arg="value"):
310+
def get_embedding(self, text: str) -> list[float]:
311+
return [0.1]
312+
313+
# Verify original __init_subclass__ was called with subclass and kwargs
314+
assert len(called_with) == 1
315+
assert called_with[0] == (SubTrackedEmbedder, "value")
316+
317+
# Verify newly created subclass was instrumented
318+
sub = SubTrackedEmbedder()
319+
sub.get_embedding("traced")
320+
spans = span_exporter.get_finished_spans()
321+
assert len(spans) == 1
322+
span_exporter.clear()
323+
324+
# Uninstrument and verify restoration
325+
instrument_agno.uninstrument()
326+
called_with.clear()
327+
328+
class SubTrackedEmbedderAfter(TrackedBaseEmbedder, custom_arg="after"):
329+
def get_embedding(self, text: str) -> list[float]:
330+
return [0.2]
331+
332+
assert len(called_with) == 1
333+
assert called_with[0] == (SubTrackedEmbedderAfter, "after")
334+
335+
sub_after = SubTrackedEmbedderAfter()
336+
sub_after.get_embedding("untraced")
337+
assert len(span_exporter.get_finished_spans()) == 0

0 commit comments

Comments
 (0)