Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)).
Expand Down
98 changes: 98 additions & 0 deletions dapr/clients/analytics.py
Original file line number Diff line number Diff line change
@@ -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
3 changes: 3 additions & 0 deletions dapr/clients/grpc/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()

Expand Down
93 changes: 93 additions & 0 deletions tests/clients/test_analytics.py
Original file line number Diff line number Diff line change
@@ -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()
Loading