Skip to content

Commit 36a6568

Browse files
Add telemetry for nanoarrow (#2752)
1 parent 27fa9f5 commit 36a6568

6 files changed

Lines changed: 245 additions & 2 deletions

File tree

src/snowflake/connector/_utils.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,17 @@ def get_spcs_token() -> str | None:
137137
return None
138138

139139

140+
class _NanoarrowLoader:
141+
def __init__(self):
142+
self._error: Exception | None = None
143+
144+
def set_load_error(self, err: Exception):
145+
self._error = err
146+
147+
def get_load_error(self) -> str:
148+
return str(self._error)
149+
150+
140151
class _CoreLoader:
141152
def __init__(self):
142153
self._version: bytes | None = None
@@ -328,6 +339,7 @@ def get_load_time(self) -> float | None:
328339

329340

330341
_core_loader = _CoreLoader()
342+
_nanoarrow_loader = _NanoarrowLoader()
331343

332344

333345
def build_minicore_usage_for_session() -> dict[str, str | None]:
@@ -347,3 +359,12 @@ def build_minicore_usage_for_telemetry() -> dict[str, str | None]:
347359
"CORE_LOAD_TIME": _core_loader.get_load_time(),
348360
**build_minicore_usage_for_session(),
349361
}
362+
363+
364+
def build_nanoarrow_usage_for_telemetry() -> dict[str, str | None]:
365+
return {
366+
"OS": OPERATING_SYSTEM,
367+
"OS_VERSION": OS_VERSION,
368+
"NANOARROW_LOAD_ERROR": _nanoarrow_loader.get_load_error(),
369+
"ISA": ISA,
370+
}

src/snowflake/connector/connection.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
_DEFAULT_VALUE_SERVER_DOP_CAP_FOR_FILE_TRANSFER,
4444
_VARIABLE_NAME_SERVER_DOP_CAP_FOR_FILE_TRANSFER,
4545
build_minicore_usage_for_telemetry,
46+
build_nanoarrow_usage_for_telemetry,
4647
)
4748
from .auth import (
4849
FIRST_PARTY_AUTHENTICATORS,
@@ -683,6 +684,7 @@ def __init__(
683684

684685
# get the imported modules from sys.modules
685686
self._log_telemetry_imported_packages()
687+
self._log_nanoarrow_import()
686688
self._log_minicore_import()
687689
# check SNOW-1218851 for long term improvement plan to refactor ocsp code
688690
atexit.register(self._close_at_exit)
@@ -2542,6 +2544,19 @@ def _log_minicore_import(self):
25422544
)
25432545
)
25442546

2547+
def _log_nanoarrow_import(self):
2548+
ts = get_time_millis()
2549+
self._log_telemetry(
2550+
TelemetryData.from_telemetry_data_dict(
2551+
from_dict={
2552+
TelemetryField.KEY_TYPE.value: TelemetryField.NANOARROW_IMPORT.value,
2553+
TelemetryField.KEY_VALUE.value: build_nanoarrow_usage_for_telemetry(),
2554+
},
2555+
timestamp=ts,
2556+
connection=self,
2557+
)
2558+
)
2559+
25452560
def _log_telemetry_imported_packages(self) -> None:
25462561
if self._log_imported_packages_in_telemetry:
25472562
# filter out duplicates caused by submodules

src/snowflake/connector/cursor.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
from ._sql_util import get_file_transfer_type
4343
from ._utils import (
4444
REQUEST_ID_STATEMENT_PARAM_NAME,
45+
_nanoarrow_loader,
4546
_snowflake_max_parallelism_for_file_transfer,
4647
_TrackedQueryCancellationTimer,
4748
is_uuid4,
@@ -112,6 +113,7 @@
112113
logger.warning(
113114
f"Failed to import ArrowResult. No Apache Arrow result set format can be used. ImportError: {e}",
114115
)
116+
_nanoarrow_loader.set_load_error(e)
115117
CAN_USE_ARROW_RESULT_FORMAT = False
116118

117119
STATEMENT_TYPE_ID_DML = 0x3000

src/snowflake/connector/telemetry.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ class TelemetryField(Enum):
4141
IMPORTED_PACKAGES = "client_imported_packages"
4242
# Core import
4343
CORE_IMPORT = "mini_core_import"
44+
NANOARROW_IMPORT = "nanoarrow_import"
4445
# multi-statement usage
4546
MULTI_STATEMENT = "client_multi_statement_query"
4647
# Keys for telemetry data sent through either in-band or out-of-band telemetry

test/integ/test_connection.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1787,7 +1787,7 @@ def test_disable_telemetry(conn_cnx, caplog):
17871787
with conn.cursor() as cur:
17881788
cur.execute("select 1").fetchall()
17891789
assert (
1790-
len(conn._telemetry._log_batch) == 4
1790+
len(conn._telemetry._log_batch) == 5
17911791
) # 4 events are import package, minicore import, fetch first, fetch last
17921792
assert "POST /telemetry/send" in caplog.text
17931793
caplog.clear()
@@ -1811,7 +1811,7 @@ def test_disable_telemetry(conn_cnx, caplog):
18111811
# test disable telemetry in the client
18121812
with caplog.at_level(logging.DEBUG):
18131813
with conn_cnx() as conn:
1814-
assert conn.telemetry_enabled and len(conn._telemetry._log_batch) == 2
1814+
assert conn.telemetry_enabled and len(conn._telemetry._log_batch) == 3
18151815
conn.telemetry_enabled = False
18161816
with conn.cursor() as cur:
18171817
cur.execute("select 1").fetchall()

test/unit/test_util.py

Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,11 @@
88

99
from snowflake.connector._utils import (
1010
_CoreLoader,
11+
_NanoarrowLoader,
1112
_TrackedQueryCancellationTimer,
1213
build_minicore_usage_for_session,
1314
build_minicore_usage_for_telemetry,
15+
build_nanoarrow_usage_for_telemetry,
1416
)
1517

1618
pytestmark = pytest.mark.skipolddriver
@@ -747,3 +749,205 @@ def test_build_minicore_usage_for_telemetry_with_load_error(self):
747749
assert result["CORE_VERSION"] is None
748750
assert result["CORE_FILE_NAME"] is None
749751
assert result["CORE_LOAD_ERROR"] == "Library not found"
752+
753+
754+
class TestNanoarrowLoader:
755+
"""Tests for the NanoarrowLoader class."""
756+
757+
def test_nanoarrow_loader_initialization(self):
758+
"""Test that NanoarrowLoader initializes with None error."""
759+
loader = _NanoarrowLoader()
760+
assert loader._error is None
761+
762+
def test_set_load_error(self):
763+
"""Test that set_load_error stores the error."""
764+
loader = _NanoarrowLoader()
765+
test_error = Exception("Test error")
766+
loader.set_load_error(test_error)
767+
assert loader._error is test_error
768+
769+
def test_get_load_error_with_error(self):
770+
"""Test get_load_error returns error message when error exists."""
771+
loader = _NanoarrowLoader()
772+
test_error = Exception("Test error message")
773+
loader._error = test_error
774+
775+
result = loader.get_load_error()
776+
777+
assert result == "Test error message"
778+
779+
780+
class TestBuildNanoarrowUsageForTelemetry:
781+
"""Tests for build_nanoarrow_usage_for_telemetry function."""
782+
783+
def test_build_nanoarrow_usage_for_telemetry_returns_expected_keys(self):
784+
"""Test that build_nanoarrow_usage_for_telemetry returns dict with expected keys."""
785+
result = build_nanoarrow_usage_for_telemetry()
786+
787+
assert isinstance(result, dict)
788+
assert "OS" in result
789+
assert "OS_VERSION" in result
790+
assert "NANOARROW_LOAD_ERROR" in result
791+
assert "ISA" in result
792+
793+
def test_build_nanoarrow_usage_for_telemetry_with_mocked_error(self):
794+
"""Test build_nanoarrow_usage_for_telemetry with mocked nanoarrow loader error."""
795+
with mock.patch(
796+
"snowflake.connector._utils._nanoarrow_loader.get_load_error",
797+
return_value="Nanoarrow load failed",
798+
):
799+
result = build_nanoarrow_usage_for_telemetry()
800+
801+
assert result["NANOARROW_LOAD_ERROR"] == "Nanoarrow load failed"
802+
803+
804+
class TestNanoarrowImportErrorInCursor:
805+
"""Tests for nanoarrow import error handling in cursor.py."""
806+
807+
def test_import_error_populates_nanoarrow_loader_error(self):
808+
"""Test that ImportError during nanoarrow import in cursor.py populates _nanoarrow_loader error field."""
809+
import importlib
810+
import sys
811+
812+
# Save the original _nanoarrow_loader state and modules
813+
from snowflake.connector._utils import _nanoarrow_loader
814+
815+
original_error = _nanoarrow_loader._error
816+
817+
# Save cursor-related modules for restoration
818+
modules_to_remove = [
819+
key
820+
for key in list(sys.modules.keys())
821+
if "snowflake.connector.cursor" in key
822+
]
823+
saved_cursor_modules = {key: sys.modules.pop(key) for key in modules_to_remove}
824+
825+
# Save nanoarrow_arrow_iterator module if present
826+
nanoarrow_key = "snowflake.connector.nanoarrow_arrow_iterator"
827+
saved_nanoarrow = sys.modules.pop(nanoarrow_key, None)
828+
829+
try:
830+
# Create a mock module that raises ImportError when accessed
831+
test_import_error = ImportError(
832+
"No module named 'nanoarrow_cpp': DLL load failed"
833+
)
834+
835+
# Inject a module that raises ImportError on import
836+
class FailingModule:
837+
def __getattr__(self, name):
838+
raise test_import_error
839+
840+
# This makes import fail when trying to access anything from the module
841+
sys.modules[nanoarrow_key] = FailingModule()
842+
843+
# Reset the nanoarrow loader error before test
844+
_nanoarrow_loader._error = None
845+
846+
# Patch the set_load_error to track if it was called
847+
original_set_load_error = _nanoarrow_loader.set_load_error
848+
set_load_error_called_with = []
849+
850+
def tracking_set_load_error(err):
851+
set_load_error_called_with.append(err)
852+
original_set_load_error(err)
853+
854+
_nanoarrow_loader.set_load_error = tracking_set_load_error
855+
856+
try:
857+
# Force reimport of cursor module - this should trigger the ImportError handling
858+
importlib.import_module("snowflake.connector.cursor")
859+
except Exception:
860+
pass # Import may fail, but the error handler should still be called
861+
862+
# Verify that set_load_error was called with an ImportError
863+
# or that the error was set directly
864+
if set_load_error_called_with:
865+
assert any(
866+
isinstance(err, (ImportError, AttributeError))
867+
for err in set_load_error_called_with
868+
)
869+
# Alternatively, check if the error was set
870+
elif _nanoarrow_loader._error is not None:
871+
assert isinstance(
872+
_nanoarrow_loader._error, (ImportError, AttributeError, Exception)
873+
)
874+
875+
finally:
876+
# Restore the set_load_error method
877+
_nanoarrow_loader.set_load_error = original_set_load_error
878+
879+
# Restore original error state
880+
_nanoarrow_loader._error = original_error
881+
882+
# Restore modules
883+
if saved_nanoarrow is not None:
884+
sys.modules[nanoarrow_key] = saved_nanoarrow
885+
elif nanoarrow_key in sys.modules:
886+
del sys.modules[nanoarrow_key]
887+
888+
sys.modules.update(saved_cursor_modules)
889+
890+
def test_nanoarrow_loader_set_load_error_simulates_cursor_behavior(self):
891+
"""Test that NanoarrowLoader.set_load_error correctly stores ImportError as cursor.py does."""
892+
# This test simulates exactly what cursor.py does on import failure:
893+
# try:
894+
# from .nanoarrow_arrow_iterator import PyArrowIterator
895+
# CAN_USE_ARROW_RESULT_FORMAT = True
896+
# except ImportError as e:
897+
# _nanoarrow_loader.set_load_error(e)
898+
# CAN_USE_ARROW_RESULT_FORMAT = False
899+
900+
loader = _NanoarrowLoader()
901+
902+
# Simulate the ImportError that occurs when nanoarrow_arrow_iterator fails to import
903+
simulated_import_error = ImportError(
904+
"No module named 'nanoarrow_cpp': cannot import name 'ArrowResult'"
905+
)
906+
907+
# This is the exact call made in cursor.py
908+
loader.set_load_error(simulated_import_error)
909+
910+
# Verify the error was stored
911+
assert loader._error is simulated_import_error
912+
assert "No module named 'nanoarrow_cpp'" in loader.get_load_error()
913+
assert "ArrowResult" in loader.get_load_error()
914+
915+
def test_nanoarrow_import_error_accessible_via_telemetry_function(self):
916+
"""Test that import error from cursor.py is accessible via build_nanoarrow_usage_for_telemetry."""
917+
from snowflake.connector._utils import _nanoarrow_loader
918+
919+
# Save original state
920+
original_error = _nanoarrow_loader._error
921+
922+
try:
923+
# Simulate the error that would be set during cursor.py import failure
924+
test_error = ImportError(
925+
"Failed to import ArrowResult: nanoarrow_cpp not found"
926+
)
927+
_nanoarrow_loader.set_load_error(test_error)
928+
929+
# Call the telemetry function and verify the error is reported
930+
result = build_nanoarrow_usage_for_telemetry()
931+
932+
assert "Failed to import ArrowResult" in result["NANOARROW_LOAD_ERROR"]
933+
assert "nanoarrow_cpp not found" in result["NANOARROW_LOAD_ERROR"]
934+
935+
finally:
936+
# Restore original error state
937+
_nanoarrow_loader._error = original_error
938+
939+
def test_dll_load_failure_error_captured_correctly(self):
940+
"""Test that DLL load failure errors during nanoarrow import are captured."""
941+
loader = _NanoarrowLoader()
942+
943+
# This simulates a common error on Windows when DLL dependencies are missing
944+
dll_error = ImportError(
945+
"DLL load failed while importing 'nanoarrow_cpp': "
946+
"The specified module could not be found."
947+
)
948+
949+
loader.set_load_error(dll_error)
950+
951+
error_msg = loader.get_load_error()
952+
assert "DLL load failed" in error_msg
953+
assert "nanoarrow_cpp" in error_msg

0 commit comments

Comments
 (0)