From f64f20ec6d2b49b2a2b013a222b63aa5651922ee Mon Sep 17 00:00:00 2001 From: cotovanu-cristian Date: Fri, 6 Mar 2026 23:33:41 +0200 Subject: [PATCH 1/2] fix: add connection string provider and reset to AppInsights event client The AppInsightsEventClient singleton initializes once and locks to whatever connection string is available at that moment. In the CLI server, module-level code triggers initialization before any job sets TELEMETRY_CONNECTION_STRING, causing custom events to route to the baked-in fallback App Insights instead of the job-specific instance. Add set_connection_string_provider() so callers can override the fallback-based resolution, and reset() so long-lived processes can re-initialize the client between job executions. Co-Authored-By: Claude Opus 4.6 --- packages/uipath/pyproject.toml | 2 +- .../uipath/src/uipath/telemetry/__init__.py | 11 +- .../uipath/src/uipath/telemetry/_track.py | 42 ++++- packages/uipath/tests/telemetry/test_track.py | 159 ++++++++++++++++++ packages/uipath/uv.lock | 62 +++++-- 5 files changed, 260 insertions(+), 16 deletions(-) diff --git a/packages/uipath/pyproject.toml b/packages/uipath/pyproject.toml index fb2709314..862b2388a 100644 --- a/packages/uipath/pyproject.toml +++ b/packages/uipath/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath" -version = "2.10.9" +version = "2.10.10" description = "Python SDK and CLI for UiPath Platform, enabling programmatic interaction with automation services, process management, and deployment tools." readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath/src/uipath/telemetry/__init__.py b/packages/uipath/src/uipath/telemetry/__init__.py index 9c4433e5f..92b3a354c 100644 --- a/packages/uipath/src/uipath/telemetry/__init__.py +++ b/packages/uipath/src/uipath/telemetry/__init__.py @@ -1,8 +1,17 @@ from ._track import ( # noqa: D104 flush_events, is_telemetry_enabled, + reset_event_client, + set_event_connection_string_provider, track, track_event, ) -__all__ = ["track", "track_event", "is_telemetry_enabled", "flush_events"] +__all__ = [ + "track", + "track_event", + "is_telemetry_enabled", + "flush_events", + "set_event_connection_string_provider", + "reset_event_client", +] diff --git a/packages/uipath/src/uipath/telemetry/_track.py b/packages/uipath/src/uipath/telemetry/_track.py index 64742b318..79c36d6a5 100644 --- a/packages/uipath/src/uipath/telemetry/_track.py +++ b/packages/uipath/src/uipath/telemetry/_track.py @@ -4,7 +4,7 @@ from functools import wraps from importlib.metadata import version from logging import INFO, WARNING, LogRecord, getLogger -from typing import Any, Callable, Dict, Mapping, Optional, Union +from typing import Any, Callable, ClassVar, Dict, Mapping, Optional, Union from opentelemetry.sdk._logs import LoggingHandler from opentelemetry.util.types import AnyValue @@ -217,6 +217,18 @@ class _AppInsightsEventClient: _initialized = False _client: Optional[Any] = None _atexit_registered = False + _connection_string_provider: ClassVar[Optional[Callable[[], Optional[str]]]] = None + + @staticmethod + def set_connection_string_provider( + provider: Callable[[], Optional[str]], + ) -> None: + """Override how the connection string is resolved. + + Args: + provider: Zero-arg callable returning a connection string or None. + """ + _AppInsightsEventClient._connection_string_provider = provider @staticmethod def _initialize() -> None: @@ -234,7 +246,10 @@ def _initialize() -> None: if not _HAS_APPINSIGHTS: return - connection_string = _get_connection_string() + if _AppInsightsEventClient._connection_string_provider: + connection_string = _AppInsightsEventClient._connection_string_provider() + else: + connection_string = _get_connection_string() if not connection_string: return @@ -330,6 +345,13 @@ def register_atexit_flush() -> None: atexit.register(_AppInsightsEventClient.flush) _AppInsightsEventClient._atexit_registered = True + @staticmethod + def reset() -> None: + """Flush pending events and reset so the next call re-initializes.""" + _AppInsightsEventClient.flush() + _AppInsightsEventClient._client = None + _AppInsightsEventClient._initialized = False + class _TelemetryClient: """A class to handle telemetry using OpenTelemetry for method tracking.""" @@ -456,6 +478,22 @@ def flush_events() -> None: _AppInsightsEventClient.flush() +def set_event_connection_string_provider( + provider: Callable[[], Optional[str]], +) -> None: + """Override how the Application Insights connection string is resolved. + + Args: + provider: Zero-arg callable returning a connection string or None. + """ + _AppInsightsEventClient.set_connection_string_provider(provider) + + +def reset_event_client() -> None: + """Flush pending events and reset so the next ``track_event`` re-initializes.""" + _AppInsightsEventClient.reset() + + def track_cli_event( name: str, properties: Optional[Dict[str, Any]] = None, diff --git a/packages/uipath/tests/telemetry/test_track.py b/packages/uipath/tests/telemetry/test_track.py index 0c47d21e7..c22ad64fa 100644 --- a/packages/uipath/tests/telemetry/test_track.py +++ b/packages/uipath/tests/telemetry/test_track.py @@ -9,6 +9,8 @@ _TelemetryClient, flush_events, is_telemetry_enabled, + reset_event_client, + set_event_connection_string_provider, track, track_event, ) @@ -81,11 +83,13 @@ def setup_method(self): """Reset AppInsightsEventClient state before each test.""" _AppInsightsEventClient._initialized = False _AppInsightsEventClient._client = None + _AppInsightsEventClient._connection_string_provider = None def teardown_method(self): """Clean up after each test.""" _AppInsightsEventClient._initialized = False _AppInsightsEventClient._client = None + _AppInsightsEventClient._connection_string_provider = None @patch("uipath.telemetry._track._CONNECTION_STRING", "$CONNECTION_STRING") def test_initialize_no_connection_string(self): @@ -255,6 +259,161 @@ def test_flush_no_client(self): # Should not raise any exception _AppInsightsEventClient.flush() + @patch("uipath.telemetry._track.TelemetryChannel") + @patch("uipath.telemetry._track.SynchronousQueue") + @patch("uipath.telemetry._track._DiagnosticSender") + @patch("uipath.telemetry._track._HAS_APPINSIGHTS", True) + @patch("uipath.telemetry._track.AppInsightsTelemetryClient") + def test_connection_string_provider_overrides_default( + self, mock_client_class, mock_sender_class, mock_queue_class, mock_channel_class + ): + """Test that a custom provider is used instead of _get_connection_string.""" + mock_client = MagicMock() + mock_client_class.return_value = mock_client + + def provider() -> str: + return ( + "InstrumentationKey=from-provider;IngestionEndpoint=https://custom.com/" + ) + + _AppInsightsEventClient.set_connection_string_provider(provider) + + _AppInsightsEventClient._initialize() + + assert _AppInsightsEventClient._client is mock_client + mock_client_class.assert_called_once_with( + "from-provider", telemetry_channel=mock_channel_class.return_value + ) + mock_sender_class.assert_called_once_with( + service_endpoint_uri="https://custom.com/v2/track" + ) + + @patch("uipath.telemetry._track._HAS_APPINSIGHTS", True) + def test_connection_string_provider_returning_none_skips_client(self): + """Test that provider returning None results in no client.""" + _AppInsightsEventClient.set_connection_string_provider(lambda: None) + + _AppInsightsEventClient._initialize() + + assert _AppInsightsEventClient._initialized is True + assert _AppInsightsEventClient._client is None + + @patch("uipath.telemetry._track.TelemetryChannel") + @patch("uipath.telemetry._track.SynchronousQueue") + @patch("uipath.telemetry._track._DiagnosticSender") + @patch("uipath.telemetry._track._HAS_APPINSIGHTS", True) + @patch("uipath.telemetry._track.AppInsightsTelemetryClient") + @patch( + "uipath.telemetry._track._CONNECTION_STRING", + "InstrumentationKey=builtin-key", + ) + def test_provider_bypasses_builtin_fallback( + self, mock_client_class, mock_sender_class, mock_queue_class, mock_channel_class + ): + """Test that provider prevents fallback to _CONNECTION_STRING.""" + mock_client = MagicMock() + mock_client_class.return_value = mock_client + + _AppInsightsEventClient.set_connection_string_provider( + lambda: "InstrumentationKey=provider-key" + ) + + with patch.dict(os.environ, {}, clear=True): + _AppInsightsEventClient._initialize() + + # Should use provider-key, not builtin-key + mock_client_class.assert_called_once_with( + "provider-key", telemetry_channel=mock_channel_class.return_value + ) + + def test_reset_clears_initialized_and_client(self): + """Test that reset clears initialized flag and client.""" + _AppInsightsEventClient._initialized = True + _AppInsightsEventClient._client = MagicMock() + + _AppInsightsEventClient.reset() + + assert _AppInsightsEventClient._initialized is False + assert _AppInsightsEventClient._client is None + + def test_reset_flushes_before_clearing(self): + """Test that reset flushes pending events before clearing.""" + mock_client = MagicMock() + _AppInsightsEventClient._initialized = True + _AppInsightsEventClient._client = mock_client + + _AppInsightsEventClient.reset() + + mock_client.flush.assert_called_once() + + @patch("uipath.telemetry._track.TelemetryChannel") + @patch("uipath.telemetry._track.SynchronousQueue") + @patch("uipath.telemetry._track._DiagnosticSender") + @patch("uipath.telemetry._track._HAS_APPINSIGHTS", True) + @patch("uipath.telemetry._track.AppInsightsTelemetryClient") + def test_reset_allows_reinitialization_with_new_connection_string( + self, mock_client_class, mock_sender_class, mock_queue_class, mock_channel_class + ): + """Test that after reset, next initialize reads current env.""" + mock_client_1 = MagicMock() + mock_client_2 = MagicMock() + mock_client_class.side_effect = [mock_client_1, mock_client_2] + + # First init with connection string A + with patch.dict( + os.environ, + {"TELEMETRY_CONNECTION_STRING": "InstrumentationKey=key-a"}, + ): + _AppInsightsEventClient._initialize() + + assert _AppInsightsEventClient._client is mock_client_1 + + # Reset + _AppInsightsEventClient.reset() + + # Second init with connection string B + with patch.dict( + os.environ, + {"TELEMETRY_CONNECTION_STRING": "InstrumentationKey=key-b"}, + ): + _AppInsightsEventClient._initialize() + + assert _AppInsightsEventClient._client is mock_client_2 + assert mock_client_class.call_count == 2 + + +class TestPublicProviderAndResetFunctions: + """Test the public set_event_connection_string_provider and reset_event_client.""" + + def setup_method(self) -> None: + """Reset state before each test.""" + _AppInsightsEventClient._initialized = False + _AppInsightsEventClient._client = None + _AppInsightsEventClient._connection_string_provider = None + + def teardown_method(self) -> None: + """Clean up after each test.""" + _AppInsightsEventClient._initialized = False + _AppInsightsEventClient._client = None + _AppInsightsEventClient._connection_string_provider = None + + def test_set_event_connection_string_provider_sets_provider(self) -> None: + """Test that the public function sets the provider on the client.""" + provider = lambda: "InstrumentationKey=test" # noqa: E731 + set_event_connection_string_provider(provider) + + assert _AppInsightsEventClient._connection_string_provider is provider + + def test_reset_event_client_resets_state(self) -> None: + """Test that the public function resets client state.""" + _AppInsightsEventClient._initialized = True + _AppInsightsEventClient._client = MagicMock() + + reset_event_client() + + assert _AppInsightsEventClient._initialized is False + assert _AppInsightsEventClient._client is None + class TestTelemetryClient: """Test _TelemetryClient functionality.""" diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index c36619790..5bfbe0828 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2531,7 +2531,7 @@ wheels = [ [[package]] name = "uipath" -version = "2.10.9" +version = "2.10.10" source = { editable = "." } dependencies = [ { name = "applicationinsights" }, @@ -2602,8 +2602,8 @@ requires-dist = [ { name = "rich", specifier = ">=14.2.0" }, { name = "tenacity", specifier = ">=9.0.0" }, { name = "truststore", specifier = ">=0.10.1" }, - { name = "uipath-core", specifier = ">=0.5.2,<0.6.0" }, - { name = "uipath-platform", specifier = ">=0.0.4,<0.1.0" }, + { name = "uipath-core", editable = "../uipath-core" }, + { name = "uipath-platform", editable = "../uipath-platform" }, { name = "uipath-runtime", specifier = ">=0.9.1,<0.10.0" }, ] @@ -2639,21 +2639,39 @@ dev = [ [[package]] name = "uipath-core" version = "0.5.6" -source = { registry = "https://pypi.org/simple" } +source = { editable = "../uipath-core" } dependencies = [ { name = "opentelemetry-instrumentation" }, { name = "opentelemetry-sdk" }, { name = "pydantic" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ea/8a/d129d33a81865f99d9134391a52f8691f557d95a18a38df4d88917b3e235/uipath_core-0.5.6.tar.gz", hash = "sha256:bebaf2e62111e844739e4f4e4dc47c48bac93b7e6fce6754502a9f4979c41888", size = 112659, upload-time = "2026-03-04T18:04:42.963Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/54/8f/77ab712518aa2a8485a558a0de245ac425e07fd8b74cfa8951550f0aea63/uipath_core-0.5.6-py3-none-any.whl", hash = "sha256:4a741fc760605165b0541b3abb6ade728bfa386e000ace00054bc43995720e5b", size = 42047, upload-time = "2026-03-04T18:04:41.606Z" }, + +[package.metadata] +requires-dist = [ + { name = "opentelemetry-instrumentation", specifier = ">=0.60b0,<1.0.0" }, + { name = "opentelemetry-sdk", specifier = ">=1.39.0,<2.0.0" }, + { name = "pydantic", specifier = ">=2.12.5,<3.0.0" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "bandit", specifier = ">=1.8.2" }, + { name = "mypy", specifier = ">=1.14.1" }, + { name = "pre-commit", specifier = ">=4.1.0" }, + { name = "pytest", specifier = ">=7.4.0" }, + { name = "pytest-asyncio", specifier = ">=1.0.0" }, + { name = "pytest-cov", specifier = ">=4.1.0" }, + { name = "pytest-httpx", specifier = ">=0.35.0" }, + { name = "pytest-mock", specifier = ">=3.11.1" }, + { name = "pytest-trio", specifier = ">=0.8.0" }, + { name = "ruff", specifier = ">=0.9.4" }, + { name = "rust-just", specifier = ">=1.39.0" }, ] [[package]] name = "uipath-platform" -version = "0.0.15" -source = { registry = "https://pypi.org/simple" } +version = "0.0.17" +source = { editable = "../uipath-platform" } dependencies = [ { name = "httpx" }, { name = "pydantic-function-models" }, @@ -2661,9 +2679,29 @@ dependencies = [ { name = "truststore" }, { name = "uipath-core" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/cc/f0/d027be9852b97cee590071c12a89ecfadfb86bb37fde2a8e5b03a56819fb/uipath_platform-0.0.15.tar.gz", hash = "sha256:d76ef6855afabbba426dbec8e2ce9450c153a883bafe14da32412b13608dde76", size = 263083, upload-time = "2026-03-05T16:08:08.345Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/fc/50/dd0ea3ecc927bc48f0ae61b897025896d8412c95cf7a1295dc65f975b80d/uipath_platform-0.0.15-py3-none-any.whl", hash = "sha256:1ddffc5d4cbe341aeb25162c2fdc4147e60fc9be4b33fdbde116906a313273b5", size = 158700, upload-time = "2026-03-05T16:08:06.552Z" }, + +[package.metadata] +requires-dist = [ + { name = "httpx", specifier = ">=0.28.1" }, + { name = "pydantic-function-models", specifier = ">=0.1.11" }, + { name = "tenacity", specifier = ">=9.0.0" }, + { name = "truststore", specifier = ">=0.10.1" }, + { name = "uipath-core", editable = "../uipath-core" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "bandit", specifier = ">=1.8.2" }, + { name = "mypy", specifier = ">=1.19.0" }, + { name = "pre-commit", specifier = ">=4.1.0" }, + { name = "pytest", specifier = ">=7.4.0" }, + { name = "pytest-asyncio", specifier = ">=1.0.0" }, + { name = "pytest-cov", specifier = ">=4.1.0" }, + { name = "pytest-httpx", specifier = ">=0.35.0" }, + { name = "pytest-mock", specifier = ">=3.11.1" }, + { name = "pytest-trio", specifier = ">=0.8.0" }, + { name = "ruff", specifier = ">=0.9.4" }, + { name = "rust-just", specifier = ">=1.39.0" }, ] [[package]] From 533b99f3fb9a86eb5c4800528514964b86d105b5 Mon Sep 17 00:00:00 2001 From: cotovanu-cristian Date: Fri, 6 Mar 2026 23:40:44 +0200 Subject: [PATCH 2/2] test: add tests for connection string provider and reset on AppInsights client Tests cover: - Provider overrides default _get_connection_string fallback - Provider returning None results in no client - Provider bypasses baked-in _CONNECTION_STRING constant - reset() clears initialized flag and client - reset() flushes before clearing - reset() allows reinitialization with new connection string - Public API functions set_event_connection_string_provider and reset_event_client Co-Authored-By: Claude Opus 4.6 --- packages/uipath/tests/telemetry/test_track.py | 5 +- packages/uipath/uv.lock | 58 ++++--------------- 2 files changed, 14 insertions(+), 49 deletions(-) diff --git a/packages/uipath/tests/telemetry/test_track.py b/packages/uipath/tests/telemetry/test_track.py index c22ad64fa..5d92830b0 100644 --- a/packages/uipath/tests/telemetry/test_track.py +++ b/packages/uipath/tests/telemetry/test_track.py @@ -399,7 +399,10 @@ def teardown_method(self) -> None: def test_set_event_connection_string_provider_sets_provider(self) -> None: """Test that the public function sets the provider on the client.""" - provider = lambda: "InstrumentationKey=test" # noqa: E731 + + def provider() -> str: + return "InstrumentationKey=test" + set_event_connection_string_provider(provider) assert _AppInsightsEventClient._connection_string_provider is provider diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index 5bfbe0828..e9834ba3d 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2602,8 +2602,8 @@ requires-dist = [ { name = "rich", specifier = ">=14.2.0" }, { name = "tenacity", specifier = ">=9.0.0" }, { name = "truststore", specifier = ">=0.10.1" }, - { name = "uipath-core", editable = "../uipath-core" }, - { name = "uipath-platform", editable = "../uipath-platform" }, + { name = "uipath-core", specifier = ">=0.5.2,<0.6.0" }, + { name = "uipath-platform", specifier = ">=0.0.4,<0.1.0" }, { name = "uipath-runtime", specifier = ">=0.9.1,<0.10.0" }, ] @@ -2639,39 +2639,21 @@ dev = [ [[package]] name = "uipath-core" version = "0.5.6" -source = { editable = "../uipath-core" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "opentelemetry-instrumentation" }, { name = "opentelemetry-sdk" }, { name = "pydantic" }, ] - -[package.metadata] -requires-dist = [ - { name = "opentelemetry-instrumentation", specifier = ">=0.60b0,<1.0.0" }, - { name = "opentelemetry-sdk", specifier = ">=1.39.0,<2.0.0" }, - { name = "pydantic", specifier = ">=2.12.5,<3.0.0" }, -] - -[package.metadata.requires-dev] -dev = [ - { name = "bandit", specifier = ">=1.8.2" }, - { name = "mypy", specifier = ">=1.14.1" }, - { name = "pre-commit", specifier = ">=4.1.0" }, - { name = "pytest", specifier = ">=7.4.0" }, - { name = "pytest-asyncio", specifier = ">=1.0.0" }, - { name = "pytest-cov", specifier = ">=4.1.0" }, - { name = "pytest-httpx", specifier = ">=0.35.0" }, - { name = "pytest-mock", specifier = ">=3.11.1" }, - { name = "pytest-trio", specifier = ">=0.8.0" }, - { name = "ruff", specifier = ">=0.9.4" }, - { name = "rust-just", specifier = ">=1.39.0" }, +sdist = { url = "https://files.pythonhosted.org/packages/ea/8a/d129d33a81865f99d9134391a52f8691f557d95a18a38df4d88917b3e235/uipath_core-0.5.6.tar.gz", hash = "sha256:bebaf2e62111e844739e4f4e4dc47c48bac93b7e6fce6754502a9f4979c41888", size = 112659, upload-time = "2026-03-04T18:04:42.963Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/8f/77ab712518aa2a8485a558a0de245ac425e07fd8b74cfa8951550f0aea63/uipath_core-0.5.6-py3-none-any.whl", hash = "sha256:4a741fc760605165b0541b3abb6ade728bfa386e000ace00054bc43995720e5b", size = 42047, upload-time = "2026-03-04T18:04:41.606Z" }, ] [[package]] name = "uipath-platform" version = "0.0.17" -source = { editable = "../uipath-platform" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "httpx" }, { name = "pydantic-function-models" }, @@ -2679,29 +2661,9 @@ dependencies = [ { name = "truststore" }, { name = "uipath-core" }, ] - -[package.metadata] -requires-dist = [ - { name = "httpx", specifier = ">=0.28.1" }, - { name = "pydantic-function-models", specifier = ">=0.1.11" }, - { name = "tenacity", specifier = ">=9.0.0" }, - { name = "truststore", specifier = ">=0.10.1" }, - { name = "uipath-core", editable = "../uipath-core" }, -] - -[package.metadata.requires-dev] -dev = [ - { name = "bandit", specifier = ">=1.8.2" }, - { name = "mypy", specifier = ">=1.19.0" }, - { name = "pre-commit", specifier = ">=4.1.0" }, - { name = "pytest", specifier = ">=7.4.0" }, - { name = "pytest-asyncio", specifier = ">=1.0.0" }, - { name = "pytest-cov", specifier = ">=4.1.0" }, - { name = "pytest-httpx", specifier = ">=0.35.0" }, - { name = "pytest-mock", specifier = ">=3.11.1" }, - { name = "pytest-trio", specifier = ">=0.8.0" }, - { name = "ruff", specifier = ">=0.9.4" }, - { name = "rust-just", specifier = ">=1.39.0" }, +sdist = { url = "https://files.pythonhosted.org/packages/1e/c9/e7568133f3a657af16b67444c4e090259941078acc62acb1e2c072903da4/uipath_platform-0.0.17.tar.gz", hash = "sha256:a2c228462d7e2642dcfc249547d9b8e94ba1c72b68f16ba673ee3e58204e9365", size = 264143, upload-time = "2026-03-06T20:34:22.23Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/36/d1/a1c357dbea16a8b5d5b8ae5311ff2353cc03a8b5dd15ff83b0b693687930/uipath_platform-0.0.17-py3-none-any.whl", hash = "sha256:7b88f2b4eb189877fb2f99d704fc0cbc6e4244f01dac59cf812fa8a03db95e36", size = 159073, upload-time = "2026-03-06T20:34:20.993Z" }, ] [[package]]