diff --git a/README.md b/README.md index 0b1e1389..e2000220 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 00000000..2457f08a --- /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 16379ff6..1e192fd8 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 00000000..a5b73573 --- /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()