From 9704ead4ecadae1cb1b842f72ec07efb1e743f86 Mon Sep 17 00:00:00 2001 From: Maxim Lapan Date: Tue, 18 Aug 2026 10:51:23 +0200 Subject: [PATCH 01/31] Protocol specification --- doc/index.rst | 1 + doc/protocol.rst | 53 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+) create mode 100644 doc/protocol.rst diff --git a/doc/index.rst b/doc/index.rst index 7edb639..37ee185 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -30,6 +30,7 @@ Documentation of telemetry user_guide client-python + protocol developer_guide api faq diff --git a/doc/protocol.rst b/doc/protocol.rst new file mode 100644 index 0000000..e3fca8f --- /dev/null +++ b/doc/protocol.rst @@ -0,0 +1,53 @@ +Telemetry Protocol Specification +================================ + +Exasol telemetry uses simplistic protocol sending events happened in the the software. +Every event has a timestamp attached to be used for server-side analytics. +All the data one the server are immediately aggregated and anonymized and no personal information +is transferred or stored. + +The data is transferred in json format and at the moment there are two versions of the protocol. + +Version 0.1 +----------- +.. code:: json + + { + "version": "0.1", + "timestamp": 1787036195, + "features": { + "mcp-server.started": [1787036195] + } + } + +Transferred data has the following fields: + +- ``version``: string specifying the protocol version +- ``timestamp``: UTC timestamp of the transmission attempt +- ``features``: dictionary with pairs ``feature-name`` and vector of timestamps when the event happened. + +Recording of both event timestamp and transmission timestamp allows to check the clock discrepancies on the client +side and filter out outliers. + +Version 0.2 +----------- + +This is an extension of version 0.1, sample data is below. +.. code:: json + + { + "version": "0.2", + "category": "mcp-server", + "productVersion": "0.22", + "timestamp": 1787036195, + "features": { + "started": [1787036195] + } + } + +In this version we have two new top-level fields added: + +- ``category``: name of the product +- ``productVersion``: version of the product + +The name of the product is no longer prepended to the features, which makes the data more compact. \ No newline at end of file From da67d66b0b78030fb76473cb27cbc4025febcd48 Mon Sep 17 00:00:00 2001 From: Maxim Lapan Date: Mon, 24 Aug 2026 18:45:29 +0200 Subject: [PATCH 02/31] Initial refactor - sending is broken --- exasol/telemetry/client/__init__.py | 16 +++++----------- exasol/telemetry/client/config.py | 10 ++++++++++ exasol/telemetry/client/errors.py | 7 ------- exasol/telemetry/client/protocol.py | 16 ++++++++++++++-- exasol/telemetry/client/setup.py | 11 ++++------- exasol/telemetry/client/worker.py | 6 +++++- test/unit/client/conftest.py | 22 +++++++++++++--------- test/unit/client/test_setup.py | 22 +++++++++++----------- test/unit/client/test_worker.py | 13 +++++++------ 9 files changed, 69 insertions(+), 54 deletions(-) delete mode 100644 exasol/telemetry/client/errors.py diff --git a/exasol/telemetry/client/__init__.py b/exasol/telemetry/client/__init__.py index d3ecff8..327d01e 100644 --- a/exasol/telemetry/client/__init__.py +++ b/exasol/telemetry/client/__init__.py @@ -2,19 +2,13 @@ Telemetry client library for python. Public API is three methods: -- setup: initializes the library based on explicit arguments or environment variables -- shutdown: cleans up the resources and sends the data still in buffers - track: remembers the feature name in the buffer (will be sent in background) - -On error we raise exception TelemetryError with error message. +- disable: disables the telemetry +- shutdown: cleans up the resources and sends the data still in buffers """ -from exasol.telemetry.client.config import was_setup -from exasol.telemetry.client.errors import TelemetryError -from exasol.telemetry.client.setup import ( - setup, - shutdown, -) +from exasol.telemetry.client.config import disable +from exasol.telemetry.client.setup import shutdown from exasol.telemetry.client.worker import track -__all__ = ["was_setup", "setup", "shutdown", "track", "TelemetryError"] +__all__ = ["track", "disable", "shutdown"] diff --git a/exasol/telemetry/client/config.py b/exasol/telemetry/client/config.py index 76327f2..1194795 100644 --- a/exasol/telemetry/client/config.py +++ b/exasol/telemetry/client/config.py @@ -6,6 +6,8 @@ ENV_DISABLE = "EXASOL_TELEMETRY_DISABLE" # Endpoint (has to be valid http/https URL) ENV_ENDPOINT = "EXASOL_TELEMETRY_ENDPOINT" +# Enable console logging of telemetry events +ENV_LOGGING = "EXASOL_TELEMETRY_VERBOSE" # GitHub CI sets this to true during execution ENV_CI = "CI" @@ -49,3 +51,11 @@ def was_enabled() -> bool: """ conf = get() return conf is not None and conf.enabled + + +def disable(): + """ + Call disables telemetry entirely for all subsequent calls. + """ + conf = Config(enabled=False, endpoint=DEFAULT_ENDPOINT) + store(conf) diff --git a/exasol/telemetry/client/errors.py b/exasol/telemetry/client/errors.py deleted file mode 100644 index 91c1bb9..0000000 --- a/exasol/telemetry/client/errors.py +++ /dev/null @@ -1,7 +0,0 @@ -class TelemetryError(Exception): - """ - Telemetry exception, thrown from telemetry client methods. - """ - - def __init__(self, message): - super().__init__(message) diff --git a/exasol/telemetry/client/protocol.py b/exasol/telemetry/client/protocol.py index ba94fc0..156f14f 100644 --- a/exasol/telemetry/client/protocol.py +++ b/exasol/telemetry/client/protocol.py @@ -7,7 +7,7 @@ import typing as tt # Version of the protocol -VERSION = "0.1" +VERSION = "0.2" # Name of the feature Feature = str @@ -28,6 +28,12 @@ class Message: # Version of the protocol this message corresponds to. version: str + # Name of the product + category: str + + # Version of the product + productVersion: str + # Current unit timestamp when the message was created # (used by the server to get the age of the individual reports) timestamp: Timestamp @@ -38,20 +44,26 @@ class Message: def to_json(self) -> dict: return { "version": self.version, + "category": self.category, + "productVersion": self.productVersion, "timestamp": self.timestamp, "features": self.features, } @classmethod - def from_features(cls, features: Features) -> "Message": + def from_features(cls, category: str, productVersion: str, features: Features) -> "Message": """ Construct the message object from collected features. We're not deep copy of features, just store the reference of it. :param features: collection of features + :param category: name of the product + :param productVersion: version of the product :return: Message created """ return Message( version=VERSION, + category=category, + productVersion=productVersion, timestamp=get_current_ts(), features=features, ) diff --git a/exasol/telemetry/client/setup.py b/exasol/telemetry/client/setup.py index 3a34de7..b19b039 100644 --- a/exasol/telemetry/client/setup.py +++ b/exasol/telemetry/client/setup.py @@ -6,7 +6,6 @@ config, worker, ) -from exasol.telemetry.client.errors import TelemetryError def get_value( @@ -44,8 +43,7 @@ def is_valid_endpoint_url(url: str) -> bool: def setup(endpoint: tt.Optional[str] = None, disable: tt.Optional[bool] = None) -> bool: """ - Telemetry client setup function. Has to be called before any other - calls to the client. + Telemetry client setup function. Explicitly given arguments have the highest priority. If they are not given, we check the environment variables (EXASOL_TELEMETRY_XXX), @@ -58,7 +56,6 @@ def setup(endpoint: tt.Optional[str] = None, disable: tt.Optional[bool] = None) :param disable: If True, disable telemetry communication and data accumulation. - :raises TelemetryError: if error has happened :returns True if telemetry is active according to configuration, False if it was disabled """ @@ -85,7 +82,7 @@ def setup(endpoint: tt.Optional[str] = None, disable: tt.Optional[bool] = None) enabled = not val_disabled if enabled and not is_valid_endpoint_url(val_endpoint): - raise TelemetryError("Endpoint is invalid: " + val_endpoint) + enabled = False conf = config.Config(endpoint=val_endpoint, enabled=enabled) config.store(conf) @@ -101,6 +98,6 @@ def shutdown(flush_buffers: bool = True): so some values might be lost. """ if not config.was_setup(): - raise TelemetryError("Telemetry was not initialized") + return worker.stop_worker(flush_buffers) - config.store(None) + config.disable() diff --git a/exasol/telemetry/client/worker.py b/exasol/telemetry/client/worker.py index cb11f4c..4ec1fc6 100644 --- a/exasol/telemetry/client/worker.py +++ b/exasol/telemetry/client/worker.py @@ -169,6 +169,8 @@ def start_worker() -> bool: if _worker is not None: return False + if not config.was_enabled(): + return False _queue = queue.Queue(maxsize=MAX_QUEUE_CAPACITY) _worker = threading.Thread(target=worker_proc, args=(_queue,)) _worker.start() @@ -193,9 +195,11 @@ def stop_worker(flush_buffers: bool): _queue = None -def track(feature: protocol.Feature): +def track(category: str, product_version: str, feature: protocol.Feature): """ Track feature usage. Library has to be initialized with `setup()` call. + :param category: product name + :param product_version: product version :param feature: string feature to track. """ if not config.was_enabled(): diff --git a/test/unit/client/conftest.py b/test/unit/client/conftest.py index 9923c22..a4f329d 100644 --- a/test/unit/client/conftest.py +++ b/test/unit/client/conftest.py @@ -1,22 +1,17 @@ import pytest -from exasol.telemetry.client import ( - TelemetryError, - config, -) +from exasol.telemetry.client import config from exasol.telemetry.client.setup import shutdown @pytest.fixture def telemetry_reset(): """ - Call `shutdown()` after the test. + Resets the telemetry into initial state """ yield - try: - shutdown() - except TelemetryError: - pass + shutdown() + config.store(None) @pytest.fixture() @@ -26,3 +21,12 @@ def telemetry_unset_ci(monkeypatch): """ monkeypatch.delenv(config.ENV_CI, raising=False) yield + + +@pytest.fixture() +def telemetry_unset_disable(monkeypatch): + """ + Temporary remove EXASOL_TELEMETRY_DISABLE env variable if present + """ + monkeypatch.delenv(config.ENV_DISABLE, raising=False) + yield diff --git a/test/unit/client/test_setup.py b/test/unit/client/test_setup.py index 9518a10..170d426 100644 --- a/test/unit/client/test_setup.py +++ b/test/unit/client/test_setup.py @@ -1,16 +1,16 @@ import pytest -from exasol.telemetry.client import * from exasol.telemetry.client import config from exasol.telemetry.client.setup import ( get_value, is_valid_endpoint_url, + setup ) -def test_error_when_not_initialized(): - with pytest.raises(TelemetryError, match="not initialized"): - shutdown() +# def test_error_when_not_initialized(): +# with pytest.raises(TelemetryError, match="not initialized"): +# shutdown() @pytest.mark.parametrize( @@ -65,9 +65,9 @@ def test_setup_explicit_disabled(telemetry_reset): def test_setup_wrong_endpoint(telemetry_reset, telemetry_unset_ci): - with pytest.raises(TelemetryError, match="Endpoint is invalid"): - setup("ftp://test.com") - assert not config.was_setup() + assert not setup("ftp://test.com") + assert config.was_setup() + assert not config.was_enabled() def test_setup_env_disabled(monkeypatch, telemetry_reset): @@ -79,14 +79,14 @@ def test_setup_env_disabled(monkeypatch, telemetry_reset): assert not setup(disable=False) -def test_setup_env_enabled(monkeypatch, telemetry_reset, telemetry_unset_ci): +def test_setup_env_enabled(monkeypatch, telemetry_reset, telemetry_unset_ci, telemetry_unset_disable): monkeypatch.setenv(config.ENV_ENDPOINT, "http://test") assert setup() assert config.was_enabled() assert config.get().endpoint == "http://test" -def test_setup_defaults(telemetry_reset, telemetry_unset_ci): +def test_setup_defaults(telemetry_reset, telemetry_unset_ci, telemetry_unset_disable): assert setup() == (not config.DEFAULT_DISABLED) assert config.get().endpoint == config.DEFAULT_ENDPOINT @@ -97,11 +97,11 @@ def test_setup_ci_true(monkeypatch, telemetry_reset): assert not config.was_enabled() -def test_setup_ci_true_explicit(monkeypatch, telemetry_reset): +def test_setup_ci_true_explicit(monkeypatch, telemetry_reset, telemetry_unset_disable): monkeypatch.setenv(config.ENV_CI, "true") assert setup(disable=False) -def test_setup_ci_false(monkeypatch, telemetry_reset): +def test_setup_ci_false(monkeypatch, telemetry_reset, telemetry_unset_disable): monkeypatch.setenv(config.ENV_CI, "t") assert setup() diff --git a/test/unit/client/test_worker.py b/test/unit/client/test_worker.py index 675b875..0ac9cc1 100644 --- a/test/unit/client/test_worker.py +++ b/test/unit/client/test_worker.py @@ -3,6 +3,7 @@ from unittest import mock from exasol.telemetry.client import * +from exasol.telemetry.client.setup import setup from exasol.telemetry.client import ( config, protocol, @@ -15,10 +16,10 @@ def test_stop_worker_doing_nothing_without_worker(): def test_track_not_init(telemetry_reset): - worker.track("feature") + worker.track("test-product", "0.1", "feature") assert not setup(disable=True) - worker.track("feature") + worker.track("test-product", "0.1", "feature") def test_clear_expired_features(): @@ -41,12 +42,12 @@ def test_clear_expired_features(): @mock.patch("requests.post") -def test_track(post_mock: mock.MagicMock, telemetry_reset, telemetry_unset_ci): +def test_track(post_mock: mock.MagicMock, telemetry_reset, telemetry_unset_ci, telemetry_unset_disable): post_mock.return_value = mock.MagicMock(status_code=200) assert setup() - worker.track("feature1") - worker.track("feature2") + worker.track("test", "0.1", "feature1") + worker.track("test", "0.1", "feature2") shutdown(flush_buffers=True) post_mock.assert_called_once() @@ -97,7 +98,7 @@ def test_worker_proc_not_sent_when_disabled( assert not setup(disable=True) assert config.was_setup() assert not config.was_enabled() - track("test") + track("test", "0.1", "test-feature") shutdown(flush_buffers=True) mock_post.assert_not_called() From 68527e604b826172891d6cbd3af1cc1a19735cc5 Mon Sep 17 00:00:00 2001 From: Maxim Lapan Date: Tue, 25 Aug 2026 16:35:19 +0200 Subject: [PATCH 03/31] Worker refactor --- exasol/telemetry/client/protocol.py | 24 ++++--- exasol/telemetry/client/worker.py | 107 +++++++++++++++++----------- 2 files changed, 77 insertions(+), 54 deletions(-) diff --git a/exasol/telemetry/client/protocol.py b/exasol/telemetry/client/protocol.py index 156f14f..05c1fb4 100644 --- a/exasol/telemetry/client/protocol.py +++ b/exasol/telemetry/client/protocol.py @@ -9,8 +9,10 @@ # Version of the protocol VERSION = "0.2" -# Name of the feature +# type aliases Feature = str +ProductName = str +ProductVersion = str # Timestamp of the measurement Timestamp = tt.Union[int, float] @@ -28,11 +30,11 @@ class Message: # Version of the protocol this message corresponds to. version: str - # Name of the product - category: str + # Name of the product ('category' in v0.2 protocol) + product_name: ProductName # Version of the product - productVersion: str + product_version: ProductVersion # Current unit timestamp when the message was created # (used by the server to get the age of the individual reports) @@ -44,26 +46,26 @@ class Message: def to_json(self) -> dict: return { "version": self.version, - "category": self.category, - "productVersion": self.productVersion, + "category": self.product_name, + "productVersion": self.product_version, "timestamp": self.timestamp, "features": self.features, } @classmethod - def from_features(cls, category: str, productVersion: str, features: Features) -> "Message": + def from_features(cls, product_name: ProductName, product_version: ProductVersion, features: Features) -> "Message": """ Construct the message object from collected features. We're not deep copy of features, just store the reference of it. :param features: collection of features - :param category: name of the product - :param productVersion: version of the product + :param product_name: name of the product + :param product_version: version of the product :return: Message created """ return Message( version=VERSION, - category=category, - productVersion=productVersion, + product_name=product_name, + product_version=product_version, timestamp=get_current_ts(), features=features, ) diff --git a/exasol/telemetry/client/worker.py b/exasol/telemetry/client/worker.py index 4ec1fc6..f75332f 100644 --- a/exasol/telemetry/client/worker.py +++ b/exasol/telemetry/client/worker.py @@ -42,30 +42,70 @@ class WorkerCommand(enum.Enum): @dataclasses.dataclass(frozen=True) class WorkerMessage: command: WorkerCommand - feature: tt.Optional[protocol.Feature] - timestamp: tt.Optional[protocol.Timestamp] + product_name: tt.Optional[protocol.ProductName] = None + product_version: tt.Optional[protocol.ProductVersion] = None + feature: tt.Optional[protocol.Feature] = None + timestamp: tt.Optional[protocol.Timestamp] = None @classmethod - def make_track(cls, feature: protocol.Feature) -> "WorkerMessage": + def make_track( + cls, + product_name: protocol.ProductName, + product_version: protocol.ProductVersion, + feature: protocol.Feature + ) -> "WorkerMessage": return WorkerMessage( command=WorkerCommand.Track, + product_name=product_name, + product_version=product_version, feature=feature, timestamp=protocol.get_current_ts(), ) @classmethod def make_send_buffers(cls) -> "WorkerMessage": - return WorkerMessage( - command=WorkerCommand.SendBuffers, feature=None, timestamp=None - ) + return WorkerMessage(command=WorkerCommand.SendBuffers) @classmethod def make_terminate(cls) -> "WorkerMessage": - return WorkerMessage( - command=WorkerCommand.Terminate, feature=None, timestamp=None - ) + return WorkerMessage(command=WorkerCommand.Terminate) +class WorkerDeadlineQueue: + """ + Queue with deadline - moment in the future when we + want to stop waiting for a message to arrive. + """ + def __init__(self, msg_queue: queue.Queue): + self._queue = msg_queue + self._deadline_ts: tt.Optional[protocol.Timestamp] = None + + def set_deadline(self, seconds: float): + self._deadline_ts = protocol.get_current_ts() + seconds + + def deadline_expired(self) -> bool: + return protocol.get_current_ts() > self._deadline_ts + + def seconds_to_deadline(self, now: tt.Optional[protocol.Timestamp] = None) -> float: + """ + Get amount of seconds until deadline. If expired, return 0 + :param now: optional current time (used for testing) + :return: count of seconds + """ + if self._deadline_ts is None: + return 0.0 + if now is None: + now = protocol.get_current_ts() + dt = self._deadline_ts - now + return max(dt, 0.0) + + def get_msg(self) -> tt.Optional[WorkerMessage]: + try: + msg = self._queue.get(timeout=self.seconds_to_deadline()) + return msg if isinstance(msg, WorkerMessage) else None + except queue.Empty: + return None + def send_features(features: protocol.Features) -> bool: """ Internal method to send the accumulated data to endpoint. @@ -109,49 +149,26 @@ def clear_expired_features(features: protocol.Features, now: protocol.Timestamp) features.pop(key) -def get_msg_timeout( - next_sent_ts: protocol.Timestamp, now: tt.Optional[protocol.Timestamp] = None -) -> protocol.Timestamp: - """ - Get amount of seconds to wait for new message. - :param next_sent_ts: when next data push is planned - :param now: optional argument to redefine the current timestamp. Used for testing - :return: Amount of seconds to sleep before the next send attempt. - """ - if now is None: - now = protocol.get_current_ts() - dt = next_sent_ts - now - # if interval has expired, return 0 - return max(dt, 0) - - def worker_proc(msg_queue: queue.Queue): """ Worker procedure - consumes the queue, periodically sends the accumulated data. :param msg_queue: queue to consume """ data: protocol.Features = collections.defaultdict(list) - next_sent_ts = protocol.get_current_ts() + DATA_SEND_FIRST_INTERVAL_SECONDS + deadline_queue = WorkerDeadlineQueue(msg_queue) + deadline_queue.set_deadline(DATA_SEND_FIRST_INTERVAL_SECONDS) while True: - try: - msg = msg_queue.get(timeout=get_msg_timeout(next_sent_ts)) - except queue.Empty: - msg = None - now = protocol.get_current_ts() - - # if interval has expired, or we have an explicit send request, send the data - if now > next_sent_ts or ( - msg is not None and msg.command == WorkerCommand.SendBuffers - ): + msg = deadline_queue.get_msg() + if msg is None or msg.command == WorkerCommand.SendBuffers: + # deadline has expired or we've asked to flush buffers if send_features(data): data.clear() else: - clear_expired_features(data, now) - next_sent_ts = now + DATA_SEND_INTERVAL_SECONDS + clear_expired_features(data, protocol.get_current_ts()) + if deadline_queue.deadline_expired(): + deadline_queue.set_deadline(DATA_SEND_INTERVAL_SECONDS) - if not isinstance(msg, WorkerMessage): - continue if msg.command == WorkerCommand.Track: if msg.feature is not None and msg.timestamp is not None: data[msg.feature].append(msg.timestamp) @@ -195,10 +212,14 @@ def stop_worker(flush_buffers: bool): _queue = None -def track(category: str, product_version: str, feature: protocol.Feature): +def track( + product_name: protocol.ProductName, + product_version: protocol.ProductVersion, + feature: protocol.Feature +): """ Track feature usage. Library has to be initialized with `setup()` call. - :param category: product name + :param product_name: product name :param product_version: product version :param feature: string feature to track. """ @@ -208,4 +229,4 @@ def track(category: str, product_version: str, feature: protocol.Feature): global _queue if _queue is not None: if _queue.not_full: - _queue.put(WorkerMessage.make_track(feature)) + _queue.put(WorkerMessage.make_track(product_name, product_version, feature)) From e6b713263f0aa314ad03159990ac705b946ef5a9 Mon Sep 17 00:00:00 2001 From: Maxim Lapan Date: Wed, 26 Aug 2026 15:43:00 +0200 Subject: [PATCH 04/31] Worker functionality --- exasol/telemetry/client/worker.py | 68 +++++++++++++++++++++++++++---- 1 file changed, 59 insertions(+), 9 deletions(-) diff --git a/exasol/telemetry/client/worker.py b/exasol/telemetry/client/worker.py index f75332f..37c1dc0 100644 --- a/exasol/telemetry/client/worker.py +++ b/exasol/telemetry/client/worker.py @@ -106,9 +106,61 @@ def get_msg(self) -> tt.Optional[WorkerMessage]: except queue.Empty: return None -def send_features(features: protocol.Features) -> bool: + +class DataPool: + Key = tt.Tuple[protocol.ProductName, protocol.ProductVersion] + + def __init__(self): + self._pool: tt.Dict[DataPool.Key, protocol.Features] = dict() + + def is_empty(self) -> bool: + return bool(self._pool) + + def send(self) -> bool: + to_clear: tt.List[DataPool.Key] = [] + try: + for key, features in self._pool.items(): + product, version = key + if send_features(product, version, features): + to_clear.append(key) + else: + # stop on first error + break + finally: + for key in to_clear: + self._pool.pop(key) + return self.is_empty() + + def clear_expired(self, now: protocol.Timestamp): + to_clear: tt.List[DataPool.Key] = [] + for key, features in self._pool.items(): + clear_expired_features(features, now) + if not features: + to_clear.append(key) + for key in to_clear: + self._pool.pop(key) + + def append( + self, + product_name: protocol.ProductName, + product_version: protocol.ProductVersion, + feature: protocol.Feature, + timestamp: protocol.Timestamp, + ): + key = (product_name, product_version) + features = self._pool.get(key, collections.defaultdict(list)) + features[feature].append(timestamp) + + +def send_features( + product_name: protocol.ProductName, + product_version: protocol.ProductVersion, + features: protocol.Features, +) -> bool: """ Internal method to send the accumulated data to endpoint. + :param product_name: name of the product + :param product_version: version of the product :param features: data to be sent :return: True if data was sent successfully, False if something happened and we need to keep data for some time. @@ -118,7 +170,7 @@ def send_features(features: protocol.Features) -> bool: conf = config.get() if conf is None: return True - message = protocol.Message.from_features(features) + message = protocol.Message.from_features(product_name, product_version, features) try: url = conf.endpoint data = json.dumps(message.to_json()) @@ -154,7 +206,7 @@ def worker_proc(msg_queue: queue.Queue): Worker procedure - consumes the queue, periodically sends the accumulated data. :param msg_queue: queue to consume """ - data: protocol.Features = collections.defaultdict(list) + data_pool = DataPool() deadline_queue = WorkerDeadlineQueue(msg_queue) deadline_queue.set_deadline(DATA_SEND_FIRST_INTERVAL_SECONDS) @@ -162,16 +214,14 @@ def worker_proc(msg_queue: queue.Queue): msg = deadline_queue.get_msg() if msg is None or msg.command == WorkerCommand.SendBuffers: # deadline has expired or we've asked to flush buffers - if send_features(data): - data.clear() - else: - clear_expired_features(data, protocol.get_current_ts()) + if not data_pool.send(): + data_pool.clear_expired(protocol.get_current_ts()) if deadline_queue.deadline_expired(): deadline_queue.set_deadline(DATA_SEND_INTERVAL_SECONDS) if msg.command == WorkerCommand.Track: - if msg.feature is not None and msg.timestamp is not None: - data[msg.feature].append(msg.timestamp) + data_pool.append(msg.product_name, msg.product_version, + msg.feature, msg.timestamp) elif msg.command == WorkerCommand.Terminate: return From 489cb4a97f0a0eb3dec0be5bb534a00a8db8bd85 Mon Sep 17 00:00:00 2001 From: Maxim Lapan Date: Wed, 26 Aug 2026 16:16:07 +0200 Subject: [PATCH 05/31] Call setup() on track() --- exasol/telemetry/client/worker.py | 19 +++++++++++++++---- test/unit/client/test_worker.py | 1 - 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/exasol/telemetry/client/worker.py b/exasol/telemetry/client/worker.py index 37c1dc0..2cf3bb2 100644 --- a/exasol/telemetry/client/worker.py +++ b/exasol/telemetry/client/worker.py @@ -114,7 +114,7 @@ def __init__(self): self._pool: tt.Dict[DataPool.Key, protocol.Features] = dict() def is_empty(self) -> bool: - return bool(self._pool) + return not bool(self._pool) def send(self) -> bool: to_clear: tt.List[DataPool.Key] = [] @@ -148,7 +148,10 @@ def append( timestamp: protocol.Timestamp, ): key = (product_name, product_version) - features = self._pool.get(key, collections.defaultdict(list)) + features = self._pool.get(key) + if features is None: + features = collections.defaultdict(list) + self._pool[key] = features features[feature].append(timestamp) @@ -218,7 +221,8 @@ def worker_proc(msg_queue: queue.Queue): data_pool.clear_expired(protocol.get_current_ts()) if deadline_queue.deadline_expired(): deadline_queue.set_deadline(DATA_SEND_INTERVAL_SECONDS) - + if msg is None: + continue if msg.command == WorkerCommand.Track: data_pool.append(msg.product_name, msg.product_version, msg.feature, msg.timestamp) @@ -262,17 +266,24 @@ def stop_worker(flush_buffers: bool): _queue = None +def _do_setup(): + from .setup import setup + setup() + + def track( product_name: protocol.ProductName, product_version: protocol.ProductVersion, feature: protocol.Feature ): """ - Track feature usage. Library has to be initialized with `setup()` call. + Track feature usage. :param product_name: product name :param product_version: product version :param feature: string feature to track. """ + if not config.was_setup(): + _do_setup() if not config.was_enabled(): return diff --git a/test/unit/client/test_worker.py b/test/unit/client/test_worker.py index 0ac9cc1..01b3a2e 100644 --- a/test/unit/client/test_worker.py +++ b/test/unit/client/test_worker.py @@ -45,7 +45,6 @@ def test_clear_expired_features(): def test_track(post_mock: mock.MagicMock, telemetry_reset, telemetry_unset_ci, telemetry_unset_disable): post_mock.return_value = mock.MagicMock(status_code=200) - assert setup() worker.track("test", "0.1", "feature1") worker.track("test", "0.1", "feature2") shutdown(flush_buffers=True) From 8e8fd2a33a4c4c774e052da18cb7aa0020729442 Mon Sep 17 00:00:00 2001 From: Maxim Lapan Date: Wed, 26 Aug 2026 17:10:20 +0200 Subject: [PATCH 06/31] Tests --- exasol/telemetry/client/worker.py | 2 +- test/unit/client/test_worker.py | 38 ++++++++++++++----------------- 2 files changed, 18 insertions(+), 22 deletions(-) diff --git a/exasol/telemetry/client/worker.py b/exasol/telemetry/client/worker.py index 2cf3bb2..09c7247 100644 --- a/exasol/telemetry/client/worker.py +++ b/exasol/telemetry/client/worker.py @@ -84,7 +84,7 @@ def set_deadline(self, seconds: float): self._deadline_ts = protocol.get_current_ts() + seconds def deadline_expired(self) -> bool: - return protocol.get_current_ts() > self._deadline_ts + return self._deadline_ts is None or protocol.get_current_ts() > self._deadline_ts def seconds_to_deadline(self, now: tt.Optional[protocol.Timestamp] = None) -> float: """ diff --git a/test/unit/client/test_worker.py b/test/unit/client/test_worker.py index 01b3a2e..af7531f 100644 --- a/test/unit/client/test_worker.py +++ b/test/unit/client/test_worker.py @@ -51,28 +51,26 @@ def test_track(post_mock: mock.MagicMock, telemetry_reset, telemetry_unset_ci, t post_mock.assert_called_once() -def test_get_msg_timeout(): - now = protocol.get_current_ts() - default_interval = worker.DATA_SEND_INTERVAL_SECONDS - assert worker.get_msg_timeout(now, now) == 0 - assert worker.get_msg_timeout(now + default_interval, now) == default_interval - assert ( - worker.get_msg_timeout(now + default_interval - 10, now) - == default_interval - 10 - ) - assert worker.get_msg_timeout(now - default_interval * 2, now) == 0 +def test_deadline_queue_deadline(): + q = worker.WorkerDeadlineQueue(queue.Queue()) + assert q.deadline_expired() + q.set_deadline(0.2) + assert not q.deadline_expired() + assert q.seconds_to_deadline() > 0.0 + time.sleep(1) + assert q.deadline_expired() def test_send_features_not_conf(): assert config.get() is None - assert worker.send_features({}) - assert worker.send_features({"f": [1]}) + assert worker.send_features("prod", "ver", {}) + assert worker.send_features("prod", "ver", {"f": [1]}) def test_send_features_wrong_endpoint(telemetry_reset, caplog): caplog.set_level("DEBUG") assert setup(endpoint="http://non-existent-domain.weird", disable=False) - assert not worker.send_features({"f": [1]}) + assert not worker.send_features("prod", "ver", {"f": [1]}) assert "Features send error" in caplog.text assert "Name or service not known" in caplog.text @@ -80,10 +78,9 @@ def test_send_features_wrong_endpoint(telemetry_reset, caplog): # Make sure that first buffer is sent quickly after the initialization @mock.patch("requests.post", return_value=mock.MagicMock(status_code=200)) def test_worker_proc_sent_quick( - mock_post: mock.MagicMock, telemetry_reset, telemetry_unset_ci + mock_post: mock.MagicMock, telemetry_reset, telemetry_unset_ci, telemetry_unset_disable ): - assert setup() - track("test") + track("product", "ver", "test") time.sleep(worker.DATA_SEND_FIRST_INTERVAL_SECONDS * 2) shutdown(flush_buffers=False) mock_post.assert_called_once() @@ -92,7 +89,7 @@ def test_worker_proc_sent_quick( # Make sure that features are not sent if not enabled @mock.patch("requests.post") def test_worker_proc_not_sent_when_disabled( - mock_post: mock.MagicMock, telemetry_reset, telemetry_unset_ci + mock_post: mock.MagicMock, telemetry_reset, telemetry_unset_ci, telemetry_unset_disable ): assert not setup(disable=True) assert config.was_setup() @@ -106,7 +103,7 @@ def test_worker_proc_not_sent_when_disabled( @mock.patch("exasol.telemetry.client.worker.send_features") def test_worker_proc_no_send(mock_send_features: mock.MagicMock): msg_queue = queue.Queue() - msg_queue.put(worker.WorkerMessage.make_track("f1")) + msg_queue.put(worker.WorkerMessage.make_track("prod", "ver", "f1")) msg_queue.put(worker.WorkerMessage.make_terminate()) worker.worker_proc(msg_queue) @@ -116,7 +113,7 @@ def test_worker_proc_no_send(mock_send_features: mock.MagicMock): @mock.patch("exasol.telemetry.client.worker.send_features", return_value=True) def test_worker_proc_send_success(mock_send_features: mock.MagicMock): msg_queue = queue.Queue() - msg_queue.put(worker.WorkerMessage.make_track("f1")) + msg_queue.put(worker.WorkerMessage.make_track("prod", "ver", "f1")) msg_queue.put(worker.WorkerMessage.make_send_buffers()) msg_queue.put(worker.WorkerMessage.make_terminate()) @@ -132,9 +129,8 @@ def test_worker_proc_send_fail( mock_send_features: mock.MagicMock, mock_clear_expired_features: mock.MagicMock ): msg_queue = queue.Queue() - msg_queue.put(worker.WorkerMessage.make_track("f1")) + msg_queue.put(worker.WorkerMessage.make_track("prod", "ver", "f1")) msg_queue.put(None) - msg_queue.put(worker.WorkerMessage.make_send_buffers()) msg_queue.put(worker.WorkerMessage.make_terminate()) worker.worker_proc(msg_queue) From 95ef1928b71e4b1e7370b9eb52e05c7eceb20953 Mon Sep 17 00:00:00 2001 From: Maxim Lapan Date: Wed, 26 Aug 2026 17:15:06 +0200 Subject: [PATCH 07/31] More tests --- test/unit/client/test_setup.py | 13 ++++++++++--- test/unit/client/test_worker.py | 1 + 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/test/unit/client/test_setup.py b/test/unit/client/test_setup.py index 170d426..e315280 100644 --- a/test/unit/client/test_setup.py +++ b/test/unit/client/test_setup.py @@ -1,10 +1,12 @@ import pytest from exasol.telemetry.client import config +from exasol.telemetry.client.config import was_setup from exasol.telemetry.client.setup import ( get_value, is_valid_endpoint_url, - setup + setup, + shutdown, ) @@ -64,13 +66,13 @@ def test_setup_explicit_disabled(telemetry_reset): assert config.get().endpoint.startswith("https") -def test_setup_wrong_endpoint(telemetry_reset, telemetry_unset_ci): +def test_setup_wrong_endpoint(telemetry_reset, telemetry_unset_ci, telemetry_unset_disable): assert not setup("ftp://test.com") assert config.was_setup() assert not config.was_enabled() -def test_setup_env_disabled(monkeypatch, telemetry_reset): +def test_setup_env_disabled(monkeypatch, telemetry_reset, telemetry_unset_ci, telemetry_unset_disable): monkeypatch.setenv(config.ENV_DISABLE, "1") assert not setup("http://endpoint") assert config.was_setup() @@ -105,3 +107,8 @@ def test_setup_ci_true_explicit(monkeypatch, telemetry_reset, telemetry_unset_di def test_setup_ci_false(monkeypatch, telemetry_reset, telemetry_unset_disable): monkeypatch.setenv(config.ENV_CI, "t") assert setup() + + +def test_shutdown_not_setup(telemetry_reset): + shutdown() + assert not was_setup() diff --git a/test/unit/client/test_worker.py b/test/unit/client/test_worker.py index af7531f..0e6a5fd 100644 --- a/test/unit/client/test_worker.py +++ b/test/unit/client/test_worker.py @@ -54,6 +54,7 @@ def test_track(post_mock: mock.MagicMock, telemetry_reset, telemetry_unset_ci, t def test_deadline_queue_deadline(): q = worker.WorkerDeadlineQueue(queue.Queue()) assert q.deadline_expired() + assert q.seconds_to_deadline() == 0.0 q.set_deadline(0.2) assert not q.deadline_expired() assert q.seconds_to_deadline() > 0.0 From eda4d36f0bb350bed86276673abdbb97f9501334 Mon Sep 17 00:00:00 2001 From: Maxim Lapan Date: Wed, 26 Aug 2026 17:19:14 +0200 Subject: [PATCH 08/31] Fixes --- exasol/telemetry/client/worker.py | 11 ++++++----- test/unit/client/conftest.py | 2 -- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/exasol/telemetry/client/worker.py b/exasol/telemetry/client/worker.py index 09c7247..806eeec 100644 --- a/exasol/telemetry/client/worker.py +++ b/exasol/telemetry/client/worker.py @@ -107,17 +107,18 @@ def get_msg(self) -> tt.Optional[WorkerMessage]: return None -class DataPool: - Key = tt.Tuple[protocol.ProductName, protocol.ProductVersion] +DataPoolKey = tt.Tuple[protocol.ProductName, protocol.ProductVersion] + +class DataPool: def __init__(self): - self._pool: tt.Dict[DataPool.Key, protocol.Features] = dict() + self._pool: tt.Dict[DataPoolKey, protocol.Features] = dict() def is_empty(self) -> bool: return not bool(self._pool) def send(self) -> bool: - to_clear: tt.List[DataPool.Key] = [] + to_clear: tt.List[DataPoolKey] = [] try: for key, features in self._pool.items(): product, version = key @@ -132,7 +133,7 @@ def send(self) -> bool: return self.is_empty() def clear_expired(self, now: protocol.Timestamp): - to_clear: tt.List[DataPool.Key] = [] + to_clear: tt.List[DataPoolKey] = [] for key, features in self._pool.items(): clear_expired_features(features, now) if not features: diff --git a/test/unit/client/conftest.py b/test/unit/client/conftest.py index a4f329d..c39d311 100644 --- a/test/unit/client/conftest.py +++ b/test/unit/client/conftest.py @@ -20,7 +20,6 @@ def telemetry_unset_ci(monkeypatch): Temporary remove CI env variable if present """ monkeypatch.delenv(config.ENV_CI, raising=False) - yield @pytest.fixture() @@ -29,4 +28,3 @@ def telemetry_unset_disable(monkeypatch): Temporary remove EXASOL_TELEMETRY_DISABLE env variable if present """ monkeypatch.delenv(config.ENV_DISABLE, raising=False) - yield From 6854b87dd19d0a452e05dcd3c84232e7b43a1f00 Mon Sep 17 00:00:00 2001 From: Maxim Lapan Date: Wed, 26 Aug 2026 17:22:36 +0200 Subject: [PATCH 09/31] Typo --- exasol/telemetry/client/worker.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/exasol/telemetry/client/worker.py b/exasol/telemetry/client/worker.py index 806eeec..f1f907c 100644 --- a/exasol/telemetry/client/worker.py +++ b/exasol/telemetry/client/worker.py @@ -167,7 +167,7 @@ def send_features( :param product_version: version of the product :param features: data to be sent :return: True if data was sent successfully, - False if something happened and we need to keep data for some time. + False if something happened, and we need to keep data for some time. """ if not features: return True From 728c7a01bdc5416a70c0acd5c51b75bdd9ff1c68 Mon Sep 17 00:00:00 2001 From: Maxim Lapan Date: Thu, 27 Aug 2026 08:35:49 +0200 Subject: [PATCH 10/31] Stupid github --- exasol/telemetry/client/worker.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/exasol/telemetry/client/worker.py b/exasol/telemetry/client/worker.py index f1f907c..d12d6e7 100644 --- a/exasol/telemetry/client/worker.py +++ b/exasol/telemetry/client/worker.py @@ -189,7 +189,7 @@ def send_features( def clear_expired_features(features: protocol.Features, now: protocol.Timestamp): - """ + """ Discard expired features from buffers. :param features: dict with features and timestamps. :param now: current timestamp From fa3c4609918aa1b75efe21bbe2baf718150b1945 Mon Sep 17 00:00:00 2001 From: Maxim Lapan Date: Thu, 27 Aug 2026 08:37:41 +0200 Subject: [PATCH 11/31] Format --- exasol/telemetry/client/protocol.py | 7 ++++- exasol/telemetry/client/worker.py | 43 ++++++++++++++++------------- test/unit/client/test_setup.py | 17 ++++++------ test/unit/client/test_worker.py | 19 ++++++++++--- 4 files changed, 54 insertions(+), 32 deletions(-) diff --git a/exasol/telemetry/client/protocol.py b/exasol/telemetry/client/protocol.py index 05c1fb4..f91c845 100644 --- a/exasol/telemetry/client/protocol.py +++ b/exasol/telemetry/client/protocol.py @@ -53,7 +53,12 @@ def to_json(self) -> dict: } @classmethod - def from_features(cls, product_name: ProductName, product_version: ProductVersion, features: Features) -> "Message": + def from_features( + cls, + product_name: ProductName, + product_version: ProductVersion, + features: Features, + ) -> "Message": """ Construct the message object from collected features. We're not deep copy of features, just store the reference of it. diff --git a/exasol/telemetry/client/worker.py b/exasol/telemetry/client/worker.py index d12d6e7..853cc48 100644 --- a/exasol/telemetry/client/worker.py +++ b/exasol/telemetry/client/worker.py @@ -49,10 +49,10 @@ class WorkerMessage: @classmethod def make_track( - cls, - product_name: protocol.ProductName, - product_version: protocol.ProductVersion, - feature: protocol.Feature + cls, + product_name: protocol.ProductName, + product_version: protocol.ProductVersion, + feature: protocol.Feature, ) -> "WorkerMessage": return WorkerMessage( command=WorkerCommand.Track, @@ -76,6 +76,7 @@ class WorkerDeadlineQueue: Queue with deadline - moment in the future when we want to stop waiting for a message to arrive. """ + def __init__(self, msg_queue: queue.Queue): self._queue = msg_queue self._deadline_ts: tt.Optional[protocol.Timestamp] = None @@ -84,7 +85,9 @@ def set_deadline(self, seconds: float): self._deadline_ts = protocol.get_current_ts() + seconds def deadline_expired(self) -> bool: - return self._deadline_ts is None or protocol.get_current_ts() > self._deadline_ts + return ( + self._deadline_ts is None or protocol.get_current_ts() > self._deadline_ts + ) def seconds_to_deadline(self, now: tt.Optional[protocol.Timestamp] = None) -> float: """ @@ -142,11 +145,11 @@ def clear_expired(self, now: protocol.Timestamp): self._pool.pop(key) def append( - self, - product_name: protocol.ProductName, - product_version: protocol.ProductVersion, - feature: protocol.Feature, - timestamp: protocol.Timestamp, + self, + product_name: protocol.ProductName, + product_version: protocol.ProductVersion, + feature: protocol.Feature, + timestamp: protocol.Timestamp, ): key = (product_name, product_version) features = self._pool.get(key) @@ -157,9 +160,9 @@ def append( def send_features( - product_name: protocol.ProductName, - product_version: protocol.ProductVersion, - features: protocol.Features, + product_name: protocol.ProductName, + product_version: protocol.ProductVersion, + features: protocol.Features, ) -> bool: """ Internal method to send the accumulated data to endpoint. @@ -189,7 +192,7 @@ def send_features( def clear_expired_features(features: protocol.Features, now: protocol.Timestamp): - """ + """ Discard expired features from buffers. :param features: dict with features and timestamps. :param now: current timestamp @@ -225,8 +228,9 @@ def worker_proc(msg_queue: queue.Queue): if msg is None: continue if msg.command == WorkerCommand.Track: - data_pool.append(msg.product_name, msg.product_version, - msg.feature, msg.timestamp) + data_pool.append( + msg.product_name, msg.product_version, msg.feature, msg.timestamp + ) elif msg.command == WorkerCommand.Terminate: return @@ -269,13 +273,14 @@ def stop_worker(flush_buffers: bool): def _do_setup(): from .setup import setup + setup() def track( - product_name: protocol.ProductName, - product_version: protocol.ProductVersion, - feature: protocol.Feature + product_name: protocol.ProductName, + product_version: protocol.ProductVersion, + feature: protocol.Feature, ): """ Track feature usage. diff --git a/test/unit/client/test_setup.py b/test/unit/client/test_setup.py index e315280..de818bc 100644 --- a/test/unit/client/test_setup.py +++ b/test/unit/client/test_setup.py @@ -10,11 +10,6 @@ ) -# def test_error_when_not_initialized(): -# with pytest.raises(TelemetryError, match="not initialized"): -# shutdown() - - @pytest.mark.parametrize( "url, expected", [ @@ -66,13 +61,17 @@ def test_setup_explicit_disabled(telemetry_reset): assert config.get().endpoint.startswith("https") -def test_setup_wrong_endpoint(telemetry_reset, telemetry_unset_ci, telemetry_unset_disable): +def test_setup_wrong_endpoint( + telemetry_reset, telemetry_unset_ci, telemetry_unset_disable +): assert not setup("ftp://test.com") assert config.was_setup() assert not config.was_enabled() -def test_setup_env_disabled(monkeypatch, telemetry_reset, telemetry_unset_ci, telemetry_unset_disable): +def test_setup_env_disabled( + monkeypatch, telemetry_reset, telemetry_unset_ci, telemetry_unset_disable +): monkeypatch.setenv(config.ENV_DISABLE, "1") assert not setup("http://endpoint") assert config.was_setup() @@ -81,7 +80,9 @@ def test_setup_env_disabled(monkeypatch, telemetry_reset, telemetry_unset_ci, te assert not setup(disable=False) -def test_setup_env_enabled(monkeypatch, telemetry_reset, telemetry_unset_ci, telemetry_unset_disable): +def test_setup_env_enabled( + monkeypatch, telemetry_reset, telemetry_unset_ci, telemetry_unset_disable +): monkeypatch.setenv(config.ENV_ENDPOINT, "http://test") assert setup() assert config.was_enabled() diff --git a/test/unit/client/test_worker.py b/test/unit/client/test_worker.py index 0e6a5fd..49a1f25 100644 --- a/test/unit/client/test_worker.py +++ b/test/unit/client/test_worker.py @@ -3,12 +3,12 @@ from unittest import mock from exasol.telemetry.client import * -from exasol.telemetry.client.setup import setup from exasol.telemetry.client import ( config, protocol, worker, ) +from exasol.telemetry.client.setup import setup def test_stop_worker_doing_nothing_without_worker(): @@ -42,7 +42,12 @@ def test_clear_expired_features(): @mock.patch("requests.post") -def test_track(post_mock: mock.MagicMock, telemetry_reset, telemetry_unset_ci, telemetry_unset_disable): +def test_track( + post_mock: mock.MagicMock, + telemetry_reset, + telemetry_unset_ci, + telemetry_unset_disable, +): post_mock.return_value = mock.MagicMock(status_code=200) worker.track("test", "0.1", "feature1") @@ -79,7 +84,10 @@ def test_send_features_wrong_endpoint(telemetry_reset, caplog): # Make sure that first buffer is sent quickly after the initialization @mock.patch("requests.post", return_value=mock.MagicMock(status_code=200)) def test_worker_proc_sent_quick( - mock_post: mock.MagicMock, telemetry_reset, telemetry_unset_ci, telemetry_unset_disable + mock_post: mock.MagicMock, + telemetry_reset, + telemetry_unset_ci, + telemetry_unset_disable, ): track("product", "ver", "test") time.sleep(worker.DATA_SEND_FIRST_INTERVAL_SECONDS * 2) @@ -90,7 +98,10 @@ def test_worker_proc_sent_quick( # Make sure that features are not sent if not enabled @mock.patch("requests.post") def test_worker_proc_not_sent_when_disabled( - mock_post: mock.MagicMock, telemetry_reset, telemetry_unset_ci, telemetry_unset_disable + mock_post: mock.MagicMock, + telemetry_reset, + telemetry_unset_ci, + telemetry_unset_disable, ): assert not setup(disable=True) assert config.was_setup() From 6f73e067a666c64ab8a45b557db6498bdb978346 Mon Sep 17 00:00:00 2001 From: Maxim Lapan Date: Thu, 27 Aug 2026 08:45:41 +0200 Subject: [PATCH 12/31] Fix doc --- doc/protocol.rst | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/doc/protocol.rst b/doc/protocol.rst index e3fca8f..20c2f60 100644 --- a/doc/protocol.rst +++ b/doc/protocol.rst @@ -32,8 +32,7 @@ side and filter out outliers. Version 0.2 ----------- -This is an extension of version 0.1, sample data is below. -.. code:: json +This is an extension of version 0.1, sample data is below :: { "version": "0.2", From 77e4fc2d014f490a8c8e585347b01acd17e76a30 Mon Sep 17 00:00:00 2001 From: Maxim Lapan Date: Thu, 27 Aug 2026 08:52:21 +0200 Subject: [PATCH 13/31] Typing --- exasol/telemetry/client/worker.py | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/exasol/telemetry/client/worker.py b/exasol/telemetry/client/worker.py index 853cc48..9308c11 100644 --- a/exasol/telemetry/client/worker.py +++ b/exasol/telemetry/client/worker.py @@ -146,11 +146,19 @@ def clear_expired(self, now: protocol.Timestamp): def append( self, - product_name: protocol.ProductName, - product_version: protocol.ProductVersion, - feature: protocol.Feature, - timestamp: protocol.Timestamp, + product_name: tt.Optional[protocol.ProductName], + product_version: tt.Optional[protocol.ProductVersion], + feature: tt.Optional[protocol.Feature], + timestamp: tt.Optional[protocol.Timestamp], ): + # should never happen, but to make linter happy :shrug + if ( + product_name is None + or product_version is None + or feature is None + or timestamp is None + ): + return key = (product_name, product_version) features = self._pool.get(key) if features is None: From 14b11019bd1953a49594430d16d53992aac3504e Mon Sep 17 00:00:00 2001 From: Maxim Lapan Date: Thu, 27 Aug 2026 08:53:43 +0200 Subject: [PATCH 14/31] Fix --- test/unit/client/conftest.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/unit/client/conftest.py b/test/unit/client/conftest.py index c39d311..b349327 100644 --- a/test/unit/client/conftest.py +++ b/test/unit/client/conftest.py @@ -14,7 +14,7 @@ def telemetry_reset(): config.store(None) -@pytest.fixture() +@pytest.fixture def telemetry_unset_ci(monkeypatch): """ Temporary remove CI env variable if present @@ -22,7 +22,7 @@ def telemetry_unset_ci(monkeypatch): monkeypatch.delenv(config.ENV_CI, raising=False) -@pytest.fixture() +@pytest.fixture def telemetry_unset_disable(monkeypatch): """ Temporary remove EXASOL_TELEMETRY_DISABLE env variable if present From d989192dda939b843d66574fb653325abfe37b47 Mon Sep 17 00:00:00 2001 From: Maxim Lapan Date: Thu, 27 Aug 2026 09:03:22 +0200 Subject: [PATCH 15/31] Fix --- exasol/telemetry/client/worker.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/exasol/telemetry/client/worker.py b/exasol/telemetry/client/worker.py index 9308c11..80a01c7 100644 --- a/exasol/telemetry/client/worker.py +++ b/exasol/telemetry/client/worker.py @@ -115,7 +115,7 @@ def get_msg(self) -> tt.Optional[WorkerMessage]: class DataPool: def __init__(self): - self._pool: tt.Dict[DataPoolKey, protocol.Features] = dict() + self._pool: tt.Dict[DataPoolKey, protocol.Features] = {} def is_empty(self) -> bool: return not bool(self._pool) From c096433ae6699862e722be8b7d22c0a272ea2d52 Mon Sep 17 00:00:00 2001 From: Maxim Lapan Date: Thu, 27 Aug 2026 09:05:21 +0200 Subject: [PATCH 16/31] py.typed --- py.typed | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 py.typed diff --git a/py.typed b/py.typed new file mode 100644 index 0000000..e69de29 From 44e17b5f5fb8d6a9d67d6299f6af6a6b79e61f51 Mon Sep 17 00:00:00 2001 From: Maxim Lapan Date: Thu, 27 Aug 2026 09:15:37 +0200 Subject: [PATCH 17/31] Documentation --- doc/client-python.rst | 36 ++++++++++-------------------------- 1 file changed, 10 insertions(+), 26 deletions(-) diff --git a/doc/client-python.rst b/doc/client-python.rst index 604f240..5977342 100644 --- a/doc/client-python.rst +++ b/doc/client-python.rst @@ -11,43 +11,27 @@ Usage ----- Once installed, package ``exasel.telemetry.client`` provides three -methods and one exception: - ``setup()``: configures the library, has to -be called once in the beginning - ``track(feature_name)``: tracks -feature as used (string) - ``shutdown()``: should be called at the end -of the program. If not called, some tracked features could be lost. - -``TelemetryError``: exception could be thrown during ``setup()`` call, -if environment variables are wrong. +methods: -Function ``was_setup()`` could be used to check the ``setup()`` was called -before (possibly in another library). +- ``track(product_name, product_version, feature_name)``: tracks feature as used (string) +- ``shutdown()``: should be called at the end of the program. If not called, some tracked features could be lost. +- ``disable()``: disables telemetry entirely for the whole process till the termination of the process. It is useful for cases when some software wants to disable telemetry even when some of its libraries are using it. + +Explicit initialization of the library is not needed --- it will be set up on the first ``track()`` call. Example of minimalistic program: .. code:: python - import logging from exasol.telemetry.client import * if __name__ == "__main__": try: - try: - if not was_setup(): - setup() - except TelemetryError as e: - logging.warning("Telemetry disabled due to error: %s", str(e)) - - track("feature1") - track("feature2") + track("hello-world", "0.1", "started") + core_of_the_program() finally: shutdown() -Exceptions ----------- - -Exception ``TelemetryError`` could be thrown from ``setup()`` and -``shutdown()`` in case of errors. Call of ``track()`` never raises exceptions, in case of errors -tracked feature is ignored. - Environment variables --------------------- @@ -58,6 +42,6 @@ environment variables. Those values also could be changed via - ``EXASOL_TELEMETRY_DISABLE`` - any value disables the telemetry data collection and sending - ``EXASOL_TELEMETRY_ENDPOINT`` - redefines telemetry endpoint url. +- ``EXASOL_TELEMETRY_VERBOSE`` -- enables logging messages from the library. Could be used to make sure integration was done properly. -In addition, if environment variable ``CI=true`` (which is the case during Github CI workflows run) -the telemetry is disabled unless explicitly enabled with ``setup()`` arguments. +In addition, if environment variable ``CI=true`` (which is the case during Github CI workflows run). From 8e5d4c8dbe635f29beb66aa879f753f111506c0b Mon Sep 17 00:00:00 2001 From: Maxim Lapan Date: Thu, 27 Aug 2026 09:19:26 +0200 Subject: [PATCH 18/31] Typo --- doc/client-python.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/client-python.rst b/doc/client-python.rst index 5977342..51d0f06 100644 --- a/doc/client-python.rst +++ b/doc/client-python.rst @@ -10,7 +10,7 @@ public repo yet) ``pip install exasol-telemetry-client`` Usage ----- -Once installed, package ``exasel.telemetry.client`` provides three +Once installed, package ``exasol.telemetry.client`` provides three methods: - ``track(product_name, product_version, feature_name)``: tracks feature as used (string) From 82a7ae2be5eaec59ca8a74e1339ce39a6f3270ee Mon Sep 17 00:00:00 2001 From: Maxim Lapan Date: Mon, 7 Sep 2026 15:08:01 +0200 Subject: [PATCH 19/31] Verbose mode --- exasol/telemetry/client/config.py | 2 +- exasol/telemetry/client/setup.py | 17 ++++++++++++++++- exasol/telemetry/client/verbose.py | 25 +++++++++++++++++++++++++ exasol/telemetry/client/worker.py | 8 ++++---- test/unit/client/conftest.py | 10 +++++++++- test/unit/client/test_setup.py | 9 ++++++++- test/unit/client/test_verbose.py | 23 +++++++++++++++++++++++ test/unit/client/test_worker.py | 6 ++++-- 8 files changed, 90 insertions(+), 10 deletions(-) create mode 100644 exasol/telemetry/client/verbose.py create mode 100644 test/unit/client/test_verbose.py diff --git a/exasol/telemetry/client/config.py b/exasol/telemetry/client/config.py index 1194795..c6fc65b 100644 --- a/exasol/telemetry/client/config.py +++ b/exasol/telemetry/client/config.py @@ -7,7 +7,7 @@ # Endpoint (has to be valid http/https URL) ENV_ENDPOINT = "EXASOL_TELEMETRY_ENDPOINT" # Enable console logging of telemetry events -ENV_LOGGING = "EXASOL_TELEMETRY_VERBOSE" +ENV_VERBOSE = "EXASOL_TELEMETRY_VERBOSE" # GitHub CI sets this to true during execution ENV_CI = "CI" diff --git a/exasol/telemetry/client/setup.py b/exasol/telemetry/client/setup.py index b19b039..b1cb416 100644 --- a/exasol/telemetry/client/setup.py +++ b/exasol/telemetry/client/setup.py @@ -5,6 +5,7 @@ from exasol.telemetry.client import ( config, worker, + verbose, ) @@ -41,6 +42,16 @@ def is_valid_endpoint_url(url: str) -> bool: return res.scheme in ("http", "https") and len(res.netloc) > 0 +def setup_verbose_if_needed(): + """ + Function enables verbose mode for telemetry prefix if env variable is set. + """ + # if env variable is not set, do nothing + if get_value(None, config.ENV_VERBOSE, None) is None: + return + verbose.setup_logging() + + def setup(endpoint: tt.Optional[str] = None, disable: tt.Optional[bool] = None) -> bool: """ Telemetry client setup function. @@ -86,7 +97,10 @@ def setup(endpoint: tt.Optional[str] = None, disable: tt.Optional[bool] = None) conf = config.Config(endpoint=val_endpoint, enabled=enabled) config.store(conf) - worker.start_worker() + if enabled: + setup_verbose_if_needed() + worker.start_worker() + verbose.log("Setup is done, enabled=%s", conf.enabled) return conf.enabled @@ -99,5 +113,6 @@ def shutdown(flush_buffers: bool = True): """ if not config.was_setup(): return + verbose.log("Shutdown") worker.stop_worker(flush_buffers) config.disable() diff --git a/exasol/telemetry/client/verbose.py b/exasol/telemetry/client/verbose.py new file mode 100644 index 0000000..ac8b1b4 --- /dev/null +++ b/exasol/telemetry/client/verbose.py @@ -0,0 +1,25 @@ +import typing as tt +import logging + +LOGGER = "exasol.telemetry.client" +LEVEL = logging.DEBUG + +logger: tt.Optional[logging.Logger] = None + + +def setup_logging(): + """ + Enable logging for our package. + """ + global logger + # prevent double-initialization + if logger is not None: + return + logger = logging.getLogger(LOGGER) + logger.setLevel(LEVEL) + + +def log(msg: str, *args, **kwargs): + global logger + if logger is not None: + logger.log(LEVEL, msg, *args, **kwargs) diff --git a/exasol/telemetry/client/worker.py b/exasol/telemetry/client/worker.py index 80a01c7..2a959e1 100644 --- a/exasol/telemetry/client/worker.py +++ b/exasol/telemetry/client/worker.py @@ -2,7 +2,6 @@ import dataclasses import enum import json -import logging import queue import threading import typing as tt @@ -12,6 +11,7 @@ from exasol.telemetry.client import ( config, protocol, + verbose, ) MAX_QUEUE_CAPACITY = 10 @@ -28,7 +28,6 @@ # for how long we keep features in buffers before removing them MAX_DATA_KEEP_SECONDS = 60 * 60 -log = logging.getLogger("worker") _worker: tt.Optional[threading.Thread] = None _queue: tt.Optional[queue.Queue] = None @@ -191,11 +190,11 @@ def send_features( data = json.dumps(message.to_json()) res = requests.post(url, data, timeout=SEND_TIMEOUT_SECONDS) if res.status_code != 200: - log.debug("Feature send error: %s", str(res)) + verbose.log("Features send error: %s", str(res)) return False return True except requests.exceptions.RequestException as e: - log.debug("Features send error: %s", str(e)) + verbose.log("Send exception: %s", str(e)) return False @@ -227,6 +226,7 @@ def worker_proc(msg_queue: queue.Queue): while True: msg = deadline_queue.get_msg() + verbose.log("Message: %s", str(msg)) if msg is None or msg.command == WorkerCommand.SendBuffers: # deadline has expired or we've asked to flush buffers if not data_pool.send(): diff --git a/test/unit/client/conftest.py b/test/unit/client/conftest.py index b349327..cf15e2f 100644 --- a/test/unit/client/conftest.py +++ b/test/unit/client/conftest.py @@ -1,6 +1,6 @@ import pytest -from exasol.telemetry.client import config +from exasol.telemetry.client import config, verbose from exasol.telemetry.client.setup import shutdown @@ -28,3 +28,11 @@ def telemetry_unset_disable(monkeypatch): Temporary remove EXASOL_TELEMETRY_DISABLE env variable if present """ monkeypatch.delenv(config.ENV_DISABLE, raising=False) + + +@pytest.fixture +def telemetry_verbose(monkeypatch): + monkeypatch.setenv(config.ENV_VERBOSE, "t") + yield + verbose.logger = None + monkeypatch.delenv(config.ENV_VERBOSE) \ No newline at end of file diff --git a/test/unit/client/test_setup.py b/test/unit/client/test_setup.py index de818bc..c676467 100644 --- a/test/unit/client/test_setup.py +++ b/test/unit/client/test_setup.py @@ -1,12 +1,13 @@ import pytest -from exasol.telemetry.client import config +from exasol.telemetry.client import config, verbose from exasol.telemetry.client.config import was_setup from exasol.telemetry.client.setup import ( get_value, is_valid_endpoint_url, setup, shutdown, + setup_verbose_if_needed, ) @@ -113,3 +114,9 @@ def test_setup_ci_false(monkeypatch, telemetry_reset, telemetry_unset_disable): def test_shutdown_not_setup(telemetry_reset): shutdown() assert not was_setup() + + +def test_verbose_mode(monkeypatch, telemetry_reset, telemetry_verbose): + assert verbose.logger is None + setup_verbose_if_needed() + assert verbose.logger is not None \ No newline at end of file diff --git a/test/unit/client/test_verbose.py b/test/unit/client/test_verbose.py new file mode 100644 index 0000000..9dba55d --- /dev/null +++ b/test/unit/client/test_verbose.py @@ -0,0 +1,23 @@ +from exasol.telemetry.client import verbose +import logging + + +def test_no_show_unconfigured(caplog, telemetry_reset): + logging.log(verbose.LEVEL, "Test") + assert "Test" not in caplog.text + caplog.clear() + + verbose.log("Test") + assert "Test" not in caplog.text + + +def test_show_configured(caplog, telemetry_reset): + verbose.setup_logging() + verbose.log("Test") + assert "Test" in caplog.text + + # second setup changes nothing + verbose.setup_logging() + caplog.clear() + verbose.log("Test2") + assert "Test2" in caplog.text diff --git a/test/unit/client/test_worker.py b/test/unit/client/test_worker.py index 49a1f25..eb81c8c 100644 --- a/test/unit/client/test_worker.py +++ b/test/unit/client/test_worker.py @@ -7,6 +7,7 @@ config, protocol, worker, + verbose, ) from exasol.telemetry.client.setup import setup @@ -74,10 +75,11 @@ def test_send_features_not_conf(): def test_send_features_wrong_endpoint(telemetry_reset, caplog): - caplog.set_level("DEBUG") + verbose.setup_logging() + caplog.set_level(verbose.LEVEL) assert setup(endpoint="http://non-existent-domain.weird", disable=False) assert not worker.send_features("prod", "ver", {"f": [1]}) - assert "Features send error" in caplog.text + assert "Send exception" in caplog.text assert "Name or service not known" in caplog.text From 00985eaf31630e9a7294fb214acc71cc7a5f9f6a Mon Sep 17 00:00:00 2001 From: Maxim Lapan Date: Mon, 7 Sep 2026 15:09:28 +0200 Subject: [PATCH 20/31] Move typed file --- py.typed => exasol/telemetry/client/py.typed | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename py.typed => exasol/telemetry/client/py.typed (100%) diff --git a/py.typed b/exasol/telemetry/client/py.typed similarity index 100% rename from py.typed rename to exasol/telemetry/client/py.typed From 352fb8d699087fee728edc0afec8d7a926116b09 Mon Sep 17 00:00:00 2001 From: Maxim Lapan Date: Mon, 7 Sep 2026 15:12:42 +0200 Subject: [PATCH 21/31] Release preparation --- doc/changes/changelog.md | 2 ++ doc/changes/changes_0.2.0.md | 10 ++++++++++ 2 files changed, 12 insertions(+) create mode 100644 doc/changes/changes_0.2.0.md diff --git a/doc/changes/changelog.md b/doc/changes/changelog.md index fd4a51d..e1874a0 100644 --- a/doc/changes/changelog.md +++ b/doc/changes/changelog.md @@ -1,6 +1,7 @@ # Changes * [unreleased](unreleased.md) +* [0.2.0](changes_0.2.0.md) * [0.1.6](changes_0.1.6.md) ```{toctree} @@ -8,5 +9,6 @@ hidden: --- unreleased +changes_0.2.0 changes_0.1.6 ``` diff --git a/doc/changes/changes_0.2.0.md b/doc/changes/changes_0.2.0.md new file mode 100644 index 0000000..765283e --- /dev/null +++ b/doc/changes/changes_0.2.0.md @@ -0,0 +1,10 @@ +# 0.2.0 - 2026-09-07 + +## Summary + +Major refactoring of client API. Support for global disable of telemetry and verbose mode. + +## Refactoring + +- #4: Support v0.2 of the protocol + API refactor +- #8: Provide py.typed marker From 0a912279f9759e6b57d6d707c4d0d81cd17ef0aa Mon Sep 17 00:00:00 2001 From: Maxim Lapan Date: Mon, 7 Sep 2026 15:13:16 +0200 Subject: [PATCH 22/31] Format fix --- exasol/telemetry/client/setup.py | 2 +- exasol/telemetry/client/verbose.py | 2 +- test/unit/client/conftest.py | 7 +++++-- test/unit/client/test_setup.py | 9 ++++++--- test/unit/client/test_verbose.py | 3 ++- test/unit/client/test_worker.py | 2 +- 6 files changed, 16 insertions(+), 9 deletions(-) diff --git a/exasol/telemetry/client/setup.py b/exasol/telemetry/client/setup.py index b1cb416..e41b2c9 100644 --- a/exasol/telemetry/client/setup.py +++ b/exasol/telemetry/client/setup.py @@ -4,8 +4,8 @@ from exasol.telemetry.client import ( config, - worker, verbose, + worker, ) diff --git a/exasol/telemetry/client/verbose.py b/exasol/telemetry/client/verbose.py index ac8b1b4..1f9d906 100644 --- a/exasol/telemetry/client/verbose.py +++ b/exasol/telemetry/client/verbose.py @@ -1,5 +1,5 @@ -import typing as tt import logging +import typing as tt LOGGER = "exasol.telemetry.client" LEVEL = logging.DEBUG diff --git a/test/unit/client/conftest.py b/test/unit/client/conftest.py index cf15e2f..6fae9bb 100644 --- a/test/unit/client/conftest.py +++ b/test/unit/client/conftest.py @@ -1,6 +1,9 @@ import pytest -from exasol.telemetry.client import config, verbose +from exasol.telemetry.client import ( + config, + verbose, +) from exasol.telemetry.client.setup import shutdown @@ -35,4 +38,4 @@ def telemetry_verbose(monkeypatch): monkeypatch.setenv(config.ENV_VERBOSE, "t") yield verbose.logger = None - monkeypatch.delenv(config.ENV_VERBOSE) \ No newline at end of file + monkeypatch.delenv(config.ENV_VERBOSE) diff --git a/test/unit/client/test_setup.py b/test/unit/client/test_setup.py index c676467..3085c4c 100644 --- a/test/unit/client/test_setup.py +++ b/test/unit/client/test_setup.py @@ -1,13 +1,16 @@ import pytest -from exasol.telemetry.client import config, verbose +from exasol.telemetry.client import ( + config, + verbose, +) from exasol.telemetry.client.config import was_setup from exasol.telemetry.client.setup import ( get_value, is_valid_endpoint_url, setup, - shutdown, setup_verbose_if_needed, + shutdown, ) @@ -119,4 +122,4 @@ def test_shutdown_not_setup(telemetry_reset): def test_verbose_mode(monkeypatch, telemetry_reset, telemetry_verbose): assert verbose.logger is None setup_verbose_if_needed() - assert verbose.logger is not None \ No newline at end of file + assert verbose.logger is not None diff --git a/test/unit/client/test_verbose.py b/test/unit/client/test_verbose.py index 9dba55d..b3cd456 100644 --- a/test/unit/client/test_verbose.py +++ b/test/unit/client/test_verbose.py @@ -1,6 +1,7 @@ -from exasol.telemetry.client import verbose import logging +from exasol.telemetry.client import verbose + def test_no_show_unconfigured(caplog, telemetry_reset): logging.log(verbose.LEVEL, "Test") diff --git a/test/unit/client/test_worker.py b/test/unit/client/test_worker.py index eb81c8c..3ad5d77 100644 --- a/test/unit/client/test_worker.py +++ b/test/unit/client/test_worker.py @@ -6,8 +6,8 @@ from exasol.telemetry.client import ( config, protocol, - worker, verbose, + worker, ) from exasol.telemetry.client.setup import setup From 298f1591a78cc5fb334ba2f509798df57c190a63 Mon Sep 17 00:00:00 2001 From: Max Lapan Date: Mon, 7 Sep 2026 17:38:40 +0200 Subject: [PATCH 23/31] Update doc/protocol.rst Co-authored-by: Torsten Kilias --- doc/protocol.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/protocol.rst b/doc/protocol.rst index 20c2f60..0690731 100644 --- a/doc/protocol.rst +++ b/doc/protocol.rst @@ -16,7 +16,7 @@ Version 0.1 "version": "0.1", "timestamp": 1787036195, "features": { - "mcp-server.started": [1787036195] + "EMCP.started": [1787036195] } } From bd38c61c05ecfe69c0795cb2a59ca5e655d9b02b Mon Sep 17 00:00:00 2001 From: Max Lapan Date: Mon, 7 Sep 2026 17:38:50 +0200 Subject: [PATCH 24/31] Update doc/protocol.rst Co-authored-by: Torsten Kilias --- doc/protocol.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/protocol.rst b/doc/protocol.rst index 0690731..293b5fe 100644 --- a/doc/protocol.rst +++ b/doc/protocol.rst @@ -36,7 +36,7 @@ This is an extension of version 0.1, sample data is below :: { "version": "0.2", - "category": "mcp-server", + "category": "EMCP", "productVersion": "0.22", "timestamp": 1787036195, "features": { From ed22aaf665ee0b4ff67453a4ac0051428382d8fd Mon Sep 17 00:00:00 2001 From: Max Lapan Date: Mon, 7 Sep 2026 17:39:03 +0200 Subject: [PATCH 25/31] Update doc/protocol.rst Co-authored-by: Torsten Kilias --- doc/protocol.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/protocol.rst b/doc/protocol.rst index 293b5fe..fd5efdc 100644 --- a/doc/protocol.rst +++ b/doc/protocol.rst @@ -3,7 +3,7 @@ Telemetry Protocol Specification Exasol telemetry uses simplistic protocol sending events happened in the the software. Every event has a timestamp attached to be used for server-side analytics. -All the data one the server are immediately aggregated and anonymized and no personal information +All the data on the server is immediately aggregated and anonymized and no personal information is transferred or stored. The data is transferred in json format and at the moment there are two versions of the protocol. From 28616bbf454577b3bcad8eab4c85b140d9ea6c5e Mon Sep 17 00:00:00 2001 From: Maxim Lapan Date: Mon, 14 Sep 2026 12:11:54 +0200 Subject: [PATCH 26/31] On disable() call shut down the worker --- exasol/telemetry/client/__init__.py | 3 +-- exasol/telemetry/client/config.py | 2 +- exasol/telemetry/client/setup.py | 9 ++++++++- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/exasol/telemetry/client/__init__.py b/exasol/telemetry/client/__init__.py index 327d01e..78bd821 100644 --- a/exasol/telemetry/client/__init__.py +++ b/exasol/telemetry/client/__init__.py @@ -7,8 +7,7 @@ - shutdown: cleans up the resources and sends the data still in buffers """ -from exasol.telemetry.client.config import disable -from exasol.telemetry.client.setup import shutdown +from exasol.telemetry.client.setup import disable, shutdown from exasol.telemetry.client.worker import track __all__ = ["track", "disable", "shutdown"] diff --git a/exasol/telemetry/client/config.py b/exasol/telemetry/client/config.py index c6fc65b..dc18595 100644 --- a/exasol/telemetry/client/config.py +++ b/exasol/telemetry/client/config.py @@ -53,7 +53,7 @@ def was_enabled() -> bool: return conf is not None and conf.enabled -def disable(): +def disable_config(): """ Call disables telemetry entirely for all subsequent calls. """ diff --git a/exasol/telemetry/client/setup.py b/exasol/telemetry/client/setup.py index e41b2c9..e5ee75d 100644 --- a/exasol/telemetry/client/setup.py +++ b/exasol/telemetry/client/setup.py @@ -115,4 +115,11 @@ def shutdown(flush_buffers: bool = True): return verbose.log("Shutdown") worker.stop_worker(flush_buffers) - config.disable() + + +def disable(): + """ + Shuts down workers and disables the telemetry globally. + """ + config.disable_config() + worker.stop_worker(flush_buffers=False) From 78601e9b18889fe62f27d5c50325dde891538868 Mon Sep 17 00:00:00 2001 From: Maxim Lapan Date: Mon, 14 Sep 2026 12:21:15 +0200 Subject: [PATCH 27/31] Format --- exasol/telemetry/client/__init__.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/exasol/telemetry/client/__init__.py b/exasol/telemetry/client/__init__.py index 78bd821..695439b 100644 --- a/exasol/telemetry/client/__init__.py +++ b/exasol/telemetry/client/__init__.py @@ -7,7 +7,10 @@ - shutdown: cleans up the resources and sends the data still in buffers """ -from exasol.telemetry.client.setup import disable, shutdown +from exasol.telemetry.client.setup import ( + disable, + shutdown, +) from exasol.telemetry.client.worker import track __all__ = ["track", "disable", "shutdown"] From 1a0441c6bdcd4ffaffb3e3d9854a6cc19656904d Mon Sep 17 00:00:00 2001 From: Maxim Lapan Date: Tue, 15 Sep 2026 09:44:59 +0200 Subject: [PATCH 28/31] Lock on deferred setup --- exasol/telemetry/client/worker.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/exasol/telemetry/client/worker.py b/exasol/telemetry/client/worker.py index 2a959e1..5277689 100644 --- a/exasol/telemetry/client/worker.py +++ b/exasol/telemetry/client/worker.py @@ -30,6 +30,7 @@ _worker: tt.Optional[threading.Thread] = None _queue: tt.Optional[queue.Queue] = None +_setup_lock: threading.Lock = threading.Lock() class WorkerCommand(enum.Enum): @@ -282,7 +283,10 @@ def stop_worker(flush_buffers: bool): def _do_setup(): from .setup import setup - setup() + global _setup_lock + + with _setup_lock: + setup() def track( From d521c01eab5320f7b79f764926907140302a10be Mon Sep 17 00:00:00 2001 From: Maxim Lapan Date: Tue, 15 Sep 2026 09:49:41 +0200 Subject: [PATCH 29/31] Fix doc --- doc/client-python.rst | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/doc/client-python.rst b/doc/client-python.rst index 51d0f06..cd9039c 100644 --- a/doc/client-python.rst +++ b/doc/client-python.rst @@ -36,12 +36,10 @@ Environment variables --------------------- To change the telemetry configuration, you can set the following -environment variables. Those values also could be changed via -``setup()`` arguments. +environment variables. - ``EXASOL_TELEMETRY_DISABLE`` - any value disables the telemetry data collection and sending - ``EXASOL_TELEMETRY_ENDPOINT`` - redefines telemetry endpoint url. - ``EXASOL_TELEMETRY_VERBOSE`` -- enables logging messages from the library. Could be used to make sure integration was done properly. - -In addition, if environment variable ``CI=true`` (which is the case during Github CI workflows run). +- ``CI=true`` -- disables telemetry to prevent tracking during CI. From 41d09e9dfd3c1a53dafde8c2ca7f414db2564898 Mon Sep 17 00:00:00 2001 From: Maxim Lapan Date: Tue, 15 Sep 2026 09:53:12 +0200 Subject: [PATCH 30/31] Update integration test --- test/integration/test_client.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/test/integration/test_client.py b/test/integration/test_client.py index 1ecc9c0..2821ce2 100644 --- a/test/integration/test_client.py +++ b/test/integration/test_client.py @@ -1,15 +1,11 @@ import pytest from exasol.telemetry.client import * -from exasol.telemetry.client import worker - -ENDPOINT = "" @pytest.mark.skip() def test_client(): - assert setup(ENDPOINT, disable=False) try: - assert worker.send_features({"test_feat": [1]}) + track("test", "0.1", "test-feature") finally: shutdown(flush_buffers=True) From a87cc2ab947ad693350a26de5b99a6b0481cfec6 Mon Sep 17 00:00:00 2001 From: Maxim Lapan Date: Tue, 15 Sep 2026 09:54:14 +0200 Subject: [PATCH 31/31] Update release version --- doc/changes/changes_0.2.0.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/changes/changes_0.2.0.md b/doc/changes/changes_0.2.0.md index 765283e..fed1d9a 100644 --- a/doc/changes/changes_0.2.0.md +++ b/doc/changes/changes_0.2.0.md @@ -1,4 +1,4 @@ -# 0.2.0 - 2026-09-07 +# 0.2.0 - 2026-09-15 ## Summary