diff --git a/packages/opentelemetry-instrumentation-voyageai/opentelemetry/instrumentation/voyageai/__init__.py b/packages/opentelemetry-instrumentation-voyageai/opentelemetry/instrumentation/voyageai/__init__.py index dcf85b9a19..129e9329b8 100644 --- a/packages/opentelemetry-instrumentation-voyageai/opentelemetry/instrumentation/voyageai/__init__.py +++ b/packages/opentelemetry-instrumentation-voyageai/opentelemetry/instrumentation/voyageai/__init__.py @@ -13,7 +13,7 @@ set_span_request_attributes, set_span_response_attributes, ) -from opentelemetry.instrumentation.voyageai.utils import dont_throw +from opentelemetry.instrumentation.voyageai.utils import dont_throw, get_call_arguments from opentelemetry.instrumentation.voyageai.version import __version__ from opentelemetry.instrumentation.instrumentor import BaseInstrumentor from opentelemetry.instrumentation.utils import ( @@ -117,8 +117,9 @@ def _wrap( ) try: - set_span_request_attributes(span, kwargs) - _handle_input_content(span, operation_name, kwargs) + call_args = get_call_arguments(wrapped, args, kwargs) + set_span_request_attributes(span, call_args) + _handle_input_content(span, operation_name, call_args) response = wrapped(*args, **kwargs) @@ -161,8 +162,9 @@ async def _awrap( ) try: - set_span_request_attributes(span, kwargs) - _handle_input_content(span, operation_name, kwargs) + call_args = get_call_arguments(wrapped, args, kwargs) + set_span_request_attributes(span, call_args) + _handle_input_content(span, operation_name, call_args) response = await wrapped(*args, **kwargs) diff --git a/packages/opentelemetry-instrumentation-voyageai/opentelemetry/instrumentation/voyageai/utils.py b/packages/opentelemetry-instrumentation-voyageai/opentelemetry/instrumentation/voyageai/utils.py index f4ff8183c3..c616e02312 100644 --- a/packages/opentelemetry-instrumentation-voyageai/opentelemetry/instrumentation/voyageai/utils.py +++ b/packages/opentelemetry-instrumentation-voyageai/opentelemetry/instrumentation/voyageai/utils.py @@ -1,4 +1,5 @@ import dataclasses +import inspect import json import logging import os @@ -75,3 +76,19 @@ def to_dict(obj): return dict(obj) except Exception: return obj + + +def get_call_arguments(wrapped, args, kwargs): + """ + Returns the arguments of a wrapped call as a single name -> value dict. + + Voyage AI methods such as ``embed(texts, model=...)`` and + ``rerank(query, documents, model)`` are commonly called with positional + arguments, so reading ``kwargs`` alone would silently drop them. + Falls back to ``kwargs`` if the signature cannot be bound. + """ + try: + bound = inspect.signature(wrapped).bind_partial(*args, **kwargs) + return dict(bound.arguments) + except Exception: + return dict(kwargs) diff --git a/packages/opentelemetry-instrumentation-voyageai/tests/test_positional_args.py b/packages/opentelemetry-instrumentation-voyageai/tests/test_positional_args.py new file mode 100644 index 0000000000..a3dc5c7cb5 --- /dev/null +++ b/packages/opentelemetry-instrumentation-voyageai/tests/test_positional_args.py @@ -0,0 +1,60 @@ +"""Regression tests: span attributes must be captured for positional arguments. + +The Voyage AI docs call ``embed(texts, model=...)`` and +``rerank(query, documents, model)`` positionally, so the instrumentation must +not rely on ``kwargs`` alone. These tests stub the network layer, so they need +neither an API key nor a VCR cassette. +""" + +import json +from types import SimpleNamespace + +import pytest +import voyageai +from opentelemetry.instrumentation.voyageai import VoyageAIInstrumentor +from opentelemetry.semconv._incubating.attributes import ( + gen_ai_attributes as GenAIAttributes, +) + +TEXTS = ["Paris is in France.", "Berlin is in Germany."] +QUERY = "What is the capital of France?" +DOCUMENTS = ["Paris", "Berlin", "Madrid"] + + +@pytest.fixture +def stubbed_instrumented_client(tracer_provider, monkeypatch): + def fake_embed(self, texts, model=None, input_type=None, **kwargs): + return SimpleNamespace(total_tokens=7, embeddings=[[0.1, 0.2]] * len(texts)) + + def fake_rerank(self, query, documents, model, top_k=None, **kwargs): + return SimpleNamespace(total_tokens=7, results=[]) + + monkeypatch.setattr(voyageai.Client, "embed", fake_embed) + monkeypatch.setattr(voyageai.Client, "rerank", fake_rerank) + + instrumentor = VoyageAIInstrumentor() + instrumentor.instrument(tracer_provider=tracer_provider) + yield voyageai.Client(api_key="test_api_key") + instrumentor.uninstrument() + + +def test_embed_positional_args(span_exporter, stubbed_instrumented_client): + stubbed_instrumented_client.embed(TEXTS, "voyage-3-lite", input_type="document") + + (span,) = span_exporter.get_finished_spans() + assert span.attributes[GenAIAttributes.GEN_AI_REQUEST_MODEL] == "voyage-3-lite" + assert span.attributes[f"{GenAIAttributes.GEN_AI_PROMPT}.0.role"] == "user" + content = json.loads(span.attributes[f"{GenAIAttributes.GEN_AI_PROMPT}.0.content"]) + assert [item["text"] for item in content] == TEXTS + + +def test_rerank_positional_args(span_exporter, stubbed_instrumented_client): + stubbed_instrumented_client.rerank(QUERY, DOCUMENTS, "rerank-2-lite", top_k=2) + + (span,) = span_exporter.get_finished_spans() + prefix = GenAIAttributes.GEN_AI_PROMPT + assert span.attributes[GenAIAttributes.GEN_AI_REQUEST_MODEL] == "rerank-2-lite" + for index, document in enumerate(DOCUMENTS): + assert span.attributes[f"{prefix}.{index}.content"] == document + assert span.attributes[f"{prefix}.{len(DOCUMENTS)}.role"] == "user" + assert span.attributes[f"{prefix}.{len(DOCUMENTS)}.content"] == QUERY