From 7c941a7a66d9fc5b050f0d77fcdc99559e0a816a Mon Sep 17 00:00:00 2001 From: "nelson.parente" Date: Tue, 18 Aug 2026 20:51:25 +0100 Subject: [PATCH] feat: report anonymous SDK usage analytics on client creation PyPI publishes only aggregate download counts, so the project has no signal about how the SDK is actually used. Sends SDK version, OS, arch and Python version once per process when a client is first constructed. Runs on a background daemon thread with a 2s timeout and swallows every error, so blocked egress and air-gapped clusters are unaffected. No application data is collected. Honors DO_NOT_TRACK, SCARF_NO_ANALYTICS and DAPR_DISABLE_ANALYTICS. Disclosed in a README 'Usage Analytics' section with opt-out instructions. --- README.md | 18 ++++++ dapr/clients/analytics.py | 98 +++++++++++++++++++++++++++++++++ dapr/clients/grpc/client.py | 3 + tests/clients/test_analytics.py | 93 +++++++++++++++++++++++++++++++ 4 files changed, 212 insertions(+) create mode 100644 dapr/clients/analytics.py create mode 100644 tests/clients/test_analytics.py diff --git a/README.md b/README.md index 0b1e13892..e2000220d 100644 --- a/README.md +++ b/README.md @@ -179,6 +179,24 @@ export DAPR_BRANCH=release-1.18 # Optional, defaults to master uv run ./tools/regen_grpcclient.sh ``` +## Usage Analytics + +The Python SDK reports an anonymous usage event the first time a Dapr client is created in a process. PyPI publishes only aggregate download counts, so this is the main signal maintainers have about how the SDK is actually used. + +**What is sent:** SDK version, operating system, architecture, and Python version — sent once per process, on a background daemon thread. No application data, configuration, app IDs, or hostnames are collected. The receiving service ([Scarf](https://scarf.sh)) uses the request IP to derive coarse company and location information and does not retain the raw IP. + +**Failure is silent by design:** the request has a 2 second timeout and every error is swallowed, so blocked egress and air-gapped clusters are unaffected and nothing is ever logged to your application's output. + +To opt out, set any of the following environment variables before starting your application: + +```bash +export DO_NOT_TRACK=1 +# or +export SCARF_NO_ANALYTICS=1 +# or +export DAPR_DISABLE_ANALYTICS=1 +``` + ## Help & Feedback Need help or have feedback on the SDK? Please open a GitHub issue or come chat with us in the `#python-sdk` channel of our Discord server ([click here to join](https://discord.gg/MySdVxrH)). diff --git a/dapr/clients/analytics.py b/dapr/clients/analytics.py new file mode 100644 index 000000000..2457f08ac --- /dev/null +++ b/dapr/clients/analytics.py @@ -0,0 +1,98 @@ +# -*- coding: utf-8 -*- + +""" +Copyright 2026 The Dapr Authors +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +""" + +import os +import platform +import threading +import urllib.parse +import urllib.request + +from dapr.version import __version__ + +# Anonymous usage reporting. PyPI publishes only aggregate download counts, so +# this is the project's only signal about how the SDK is actually used. The SDK +# reports its own version and the host platform once per process. No application +# data is collected. See the "Usage Analytics" section of the README, including +# how to opt out. +ANALYTICS_ENDPOINT = 'https://dapr.gateway.scarf.sh/dapr-event-collection' +ANALYTICS_TIMEOUT_SECONDS = 2 + +# Honors the cross-ecosystem DO_NOT_TRACK convention, Scarf's own variable, and +# a Dapr-specific opt-out. +OPT_OUT_ENV_VARS = ('DO_NOT_TRACK', 'SCARF_NO_ANALYTICS', 'DAPR_DISABLE_ANALYTICS') +_TRUTHY_VALUES = frozenset({'1', 'true', 'yes', 'on'}) + +_reported_lock = threading.Lock() +_reported = False + + +def _is_truthy(value: str) -> bool: + return value.strip().lower() in _TRUTHY_VALUES + + +def analytics_disabled() -> bool: + """Returns True when the user has opted out via any supported variable.""" + return any(_is_truthy(os.environ.get(name, '')) for name in OPT_OUT_ENV_VARS) + + +def _build_url() -> str: + params = urllib.parse.urlencode( + { + 'version': __version__, + 'os': platform.system().lower(), + 'arch': platform.machine().lower(), + 'python_version': platform.python_version(), + } + ) + return f'{ANALYTICS_ENDPOINT}?{params}' + + +def _send_event() -> None: + """Sends a single event, swallowing every failure. + + Analytics must never affect the application: a blocked egress path or an + air-gapped cluster is a normal condition, not a fault. + """ + try: + request = urllib.request.Request( + _build_url(), + headers={'User-Agent': f'dapr-python-sdk/{__version__}'}, + ) + with urllib.request.urlopen(request, timeout=ANALYTICS_TIMEOUT_SECONDS): + pass + except Exception: + pass + + +def report_analytics() -> None: + """Reports a usage event once per process, on a background daemon thread. + + Never blocks the caller and never raises. + """ + global _reported + + try: + with _reported_lock: + if _reported: + return + _reported = True + + if analytics_disabled(): + return + + thread = threading.Thread(target=_send_event, name='dapr-analytics', daemon=True) + thread.start() + except Exception: + pass diff --git a/dapr/clients/grpc/client.py b/dapr/clients/grpc/client.py index 16379ff69..1e192fd89 100644 --- a/dapr/clients/grpc/client.py +++ b/dapr/clients/grpc/client.py @@ -86,6 +86,7 @@ from dapr.clients.grpc.interceptors import DaprClientInterceptor, DaprClientTimeoutInterceptor from dapr.clients.grpc.subscription import StreamInactiveError, Subscription from dapr.clients.health import DaprHealth +from dapr.clients.analytics import report_analytics from dapr.clients.retry import RetryPolicy from dapr.common.pubsub.subscription import StreamCancelledError from dapr.conf import settings @@ -144,6 +145,8 @@ def __init__( receive limit (matches the Java SDK property of the same name). retry_policy (RetryPolicy optional): Specifies retry behaviour """ + report_analytics() + DaprHealth.wait_for_sidecar() self.retry_policy = retry_policy or RetryPolicy() diff --git a/tests/clients/test_analytics.py b/tests/clients/test_analytics.py new file mode 100644 index 000000000..a5b735738 --- /dev/null +++ b/tests/clients/test_analytics.py @@ -0,0 +1,93 @@ +# -*- coding: utf-8 -*- + +""" +Copyright 2026 The Dapr Authors +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +""" + +import unittest +from unittest.mock import patch + +from dapr.clients import analytics + + +class AnalyticsOptOutTests(unittest.TestCase): + def setUp(self): + analytics._reported = False + + def test_enabled_when_no_opt_out_set(self): + with patch.dict('os.environ', {name: '' for name in analytics.OPT_OUT_ENV_VARS}): + self.assertFalse(analytics.analytics_disabled()) + + def test_each_opt_out_variable_disables(self): + for name in analytics.OPT_OUT_ENV_VARS: + env = {other: '' for other in analytics.OPT_OUT_ENV_VARS} + env[name] = '1' + with self.subTest(variable=name): + with patch.dict('os.environ', env): + self.assertTrue(analytics.analytics_disabled()) + + def test_falsy_values_do_not_disable(self): + for value in ('0', 'false', 'no', 'off', ''): + env = {other: '' for other in analytics.OPT_OUT_ENV_VARS} + env['DO_NOT_TRACK'] = value + with self.subTest(value=value): + with patch.dict('os.environ', env): + self.assertFalse(analytics.analytics_disabled()) + + def test_truthy_values_are_case_and_space_insensitive(self): + for value in ('1', 'TRUE', ' yes ', 'On'): + env = {other: '' for other in analytics.OPT_OUT_ENV_VARS} + env['DO_NOT_TRACK'] = value + with self.subTest(value=value): + with patch.dict('os.environ', env): + self.assertTrue(analytics.analytics_disabled()) + + +class AnalyticsReportingTests(unittest.TestCase): + def setUp(self): + analytics._reported = False + + def test_no_event_sent_when_opted_out(self): + with patch.dict('os.environ', {'DO_NOT_TRACK': '1'}): + with patch.object(analytics.threading, 'Thread') as thread: + analytics.report_analytics() + thread.assert_not_called() + + def test_event_sent_once_per_process(self): + env = {name: '' for name in analytics.OPT_OUT_ENV_VARS} + with patch.dict('os.environ', env): + with patch.object(analytics.threading, 'Thread') as thread: + analytics.report_analytics() + analytics.report_analytics() + analytics.report_analytics() + self.assertEqual(thread.call_count, 1) + + def test_send_failure_is_swallowed(self): + with patch.object( + analytics.urllib.request, 'urlopen', side_effect=OSError('network unreachable') + ): + # Must not raise — blocked egress is a normal condition. + analytics._send_event() + + def test_report_never_raises(self): + with patch.object(analytics, 'analytics_disabled', side_effect=RuntimeError('boom')): + analytics.report_analytics() + + def test_url_contains_expected_dimensions(self): + url = analytics._build_url() + self.assertTrue(url.startswith(analytics.ANALYTICS_ENDPOINT)) + for key in ('version', 'os', 'arch', 'python_version'): + self.assertIn(f'{key}=', url) + + +if __name__ == '__main__': + unittest.main()