diff --git a/packages/opentelemetry-instrumentation-lancedb/opentelemetry/instrumentation/lancedb/utils.py b/packages/opentelemetry-instrumentation-lancedb/opentelemetry/instrumentation/lancedb/utils.py index 574ee55411..521c3146ec 100644 --- a/packages/opentelemetry-instrumentation-lancedb/opentelemetry/instrumentation/lancedb/utils.py +++ b/packages/opentelemetry-instrumentation-lancedb/opentelemetry/instrumentation/lancedb/utils.py @@ -1,3 +1,4 @@ +import inspect import logging import traceback from opentelemetry.instrumentation.lancedb.config import Config @@ -26,3 +27,28 @@ def wrapper(*args, **kwargs): Config.exception_logger(e) return wrapper + + +def get_call_arguments(wrapped, args, kwargs): + """ + Returns the arguments of a wrapped call as a single name -> value dict. + + SDK methods are commonly called positionally (for example + ``table.add(data)``), so reading ``kwargs`` alone would + silently drop those arguments. Extra keyword arguments collected by a + ``**kwargs`` parameter are flattened into the result. Falls back to + ``kwargs`` if the signature cannot be bound. + """ + try: + signature = inspect.signature(wrapped) + bound = signature.bind_partial(*args, **kwargs) + call_args = {} + for name, value in bound.arguments.items(): + kind = signature.parameters[name].kind + if kind is inspect.Parameter.VAR_KEYWORD: + call_args.update(value) + elif kind is not inspect.Parameter.VAR_POSITIONAL: + call_args[name] = value + return call_args + except Exception: + return dict(kwargs) diff --git a/packages/opentelemetry-instrumentation-lancedb/opentelemetry/instrumentation/lancedb/wrapper.py b/packages/opentelemetry-instrumentation-lancedb/opentelemetry/instrumentation/lancedb/wrapper.py index b5067dbfbb..b5426d1882 100644 --- a/packages/opentelemetry-instrumentation-lancedb/opentelemetry/instrumentation/lancedb/wrapper.py +++ b/packages/opentelemetry-instrumentation-lancedb/opentelemetry/instrumentation/lancedb/wrapper.py @@ -1,4 +1,4 @@ -from opentelemetry.instrumentation.lancedb.utils import dont_throw +from opentelemetry.instrumentation.lancedb.utils import dont_throw, get_call_arguments from opentelemetry.semconv.trace import SpanAttributes from opentelemetry.semconv.attributes.error_attributes import ERROR_TYPE from opentelemetry.trace.status import Status, StatusCode @@ -42,12 +42,13 @@ def _wrap(tracer, to_wrap, wrapped, instance, args, kwargs): span.set_attribute(SpanAttributes.DB_SYSTEM, "lancedb") span.set_attribute(SpanAttributes.DB_OPERATION, to_wrap.get("method")) + call_args = get_call_arguments(wrapped, args, kwargs) if to_wrap.get("method") == "add": - _set_add_attributes(span, kwargs) + _set_add_attributes(span, call_args) elif to_wrap.get("method") == "search": - _set_search_attributes(span, kwargs) + _set_search_attributes(span, call_args) elif to_wrap.get("method") == "delete": - _set_delete_attributes(span, kwargs) + _set_delete_attributes(span, call_args) try: return_value = wrapped(*args, **kwargs) diff --git a/packages/opentelemetry-instrumentation-lancedb/tests/test_positional_args.py b/packages/opentelemetry-instrumentation-lancedb/tests/test_positional_args.py new file mode 100644 index 0000000000..589524a3bb --- /dev/null +++ b/packages/opentelemetry-instrumentation-lancedb/tests/test_positional_args.py @@ -0,0 +1,49 @@ +"""Regression tests: span attributes must be captured for positional arguments. + +LanceDB documents ``table.add(data)``, ``table.search(query)`` and +``table.delete(where)`` as positional calls, so the instrumentation must not +rely on ``kwargs`` alone. These tests use a local, temporary LanceDB. +""" + +import lancedb +import pytest +from opentelemetry.semconv_ai import SpanAttributes + +ROWS = [ + {"vector": [1.3, 1.4], "item": "fizz", "price": 100.0}, + {"vector": [9.5, 56.2], "item": "buzz", "price": 200.0}, +] + + +@pytest.fixture +def table(tmp_path): + db = lancedb.connect(str(tmp_path)) + return db.create_table("positional_table", data=ROWS) + + +def _span(exporter, name): + return next(s for s in exporter.get_finished_spans() if s.name == name) + + +def test_add_positional_args(exporter, table): + exporter.clear() + table.add(ROWS) + + span = _span(exporter, "lancedb.add") + assert span.attributes.get(SpanAttributes.MILVUS_INSERT_DATA_COUNT) == 2 + + +def test_search_positional_args(exporter, table): + exporter.clear() + table.search([100, 100]).limit(2).to_pandas() + + span = _span(exporter, "lancedb.search") + assert span.attributes.get(SpanAttributes.MILVUS_SEARCH_FILTER) == "[100, 100]" + + +def test_delete_positional_args(exporter, table): + exporter.clear() + table.delete("item = 'fizz'") + + span = _span(exporter, "lancedb.delete") + assert span.attributes.get(SpanAttributes.CHROMADB_DELETE_WHERE) == "item = 'fizz'"