Repository: huggingface/huggingface_hub
Pull request #1344 (2023-02-16 18:04:43)

Add utility to send telemetry
Related to [internal discussion](https://huggingface.slack.com/archives/C02Q9UFS8AW/p1676391976224329) related to Gradio telemetry. Idea is to centralize telemetry across HF libraries in a single helper. Gradio could be a first step (cc @abidlabs @freddyaboulton @dawoodkhan82) , but it can also replace implementation from [`transformers`](https://github.com/huggingface/transformers/blob/main/src/transformers/utils/hub.py#LL125C87-L125C95) and `diffusers` (cc @sgugger @patrickvonplaten, even though this is not a priority).

Telemetry is sent to _https://huggingface.co/api/telemetry/whatever/path_ using a HEAD request. Headers contain a user_agent with library name/version + some stuff (hf_hub version, python version, pytorch/tf/...,). Additional data can be also provided in the user_agent, either with a string or with a dictionary. Entries are de-duplicated from the user_agent before being sent to the Hub.

Since telemetry is a bit sensible, it will be at the discretion of each library to decide when/where to put telemetry. There is always an opt-out option for the users to disable it globally with `HF_HUB_DISABLE_TELEMETRY` (or `HF_HUB_OFFLINE` which has [other side effects](https://huggingface.co/docs/huggingface_hub/v0.12.0/en/package_reference/environment_variables#hfhuboffline)). `DISABLE_TELEMETRY` is also supported as legacy for `transformers`. (We can also add helpers like `disable_telemetry`/`enable_telemetry` to programmatically enable/disable it we you think it's useful).

**Example:**
```py
>>> from huggingface_hub.utils import send_telemetry

# Send telemetry without library information
>>> send_telemetry("ping")

# Send telemetry to subtopic with library information
>>> send_telemetry("gradio/local_link", library_name="gradio", library_version="3.22.1")

# Send telemetry with additional data
>>> send_telemetry(
...     topic="examples",
...     library_name="transformers",
...     library_version="4.26.0",
...     user_agent={"pipeline": "text_classification", "framework": "flax"},
... )
```

**TODO:**
- [x] `send_telemetry`
- [x] unit tests
- [x] docs in package reference section
- [x] docs in environment variables sections 
- [x] (optional) run telemetry in a separate thread to avoid blocking the main thread in case of slow connections

cc @julien-c @LysandreJik who brought up the topic :)

HuggingFaceDocBuilderDev: The docs for this PR live [here](https://moon-ci-docs.huggingface.co/docs/huggingface_hub/pr_1344). All of your documentation changes will be reflected on that endpoint.
codecov[bot]: # [Codecov](https://codecov.io/gh/huggingface/huggingface_hub/pull/1344?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=huggingface) Report
Base: **83.88**% // Head: **83.93**% // Increases project coverage by **`+0.04%`** :tada:
> Coverage data is based on head [(`43e0611`)](https://codecov.io/gh/huggingface/huggingface_hub/pull/1344?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=huggingface) compared to base [(`91c3a4d`)](https://codecov.io/gh/huggingface/huggingface_hub/commit/91c3a4da1606fd6571dd4fb106a6d581b3226be3?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=huggingface).
> Patch coverage: 94.11% of modified lines in pull request are covered.

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##             main    #1344      +/-   ##
==========================================
+ Coverage   83.88%   83.93%   +0.04%     
==========================================
  Files          47       48       +1     
  Lines        4752     4773      +21     
==========================================
+ Hits         3986     4006      +20     
- Misses        766      767       +1     
```


| [Impacted Files](https://codecov.io/gh/huggingface/huggingface_hub/pull/1344?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=huggingface) | Coverage Δ | |
|---|---|---|
| [src/huggingface\_hub/utils/\_runtime.py](https://codecov.io/gh/huggingface/huggingface_hub/pull/1344?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=huggingface#diff-c3JjL2h1Z2dpbmdmYWNlX2h1Yi91dGlscy9fcnVudGltZS5weQ==) | `56.45% <0.00%> (-0.46%)` | :arrow_down: |
| [src/huggingface\_hub/constants.py](https://codecov.io/gh/huggingface/huggingface_hub/pull/1344?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=huggingface#diff-c3JjL2h1Z2dpbmdmYWNlX2h1Yi9jb25zdGFudHMucHk=) | `94.23% <100.00%> (+0.11%)` | :arrow_up: |
| [src/huggingface\_hub/utils/\_\_init\_\_.py](https://codecov.io/gh/huggingface/huggingface_hub/pull/1344?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=huggingface#diff-c3JjL2h1Z2dpbmdmYWNlX2h1Yi91dGlscy9fX2luaXRfXy5weQ==) | `100.00% <100.00%> (ø)` | |
| [src/huggingface\_hub/utils/\_headers.py](https://codecov.io/gh/huggingface/huggingface_hub/pull/1344?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=huggingface#diff-c3JjL2h1Z2dpbmdmYWNlX2h1Yi91dGlscy9faGVhZGVycy5weQ==) | `100.00% <100.00%> (ø)` | |
| [src/huggingface\_hub/utils/\_telemetry.py](https://codecov.io/gh/huggingface/huggingface_hub/pull/1344?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=huggingface#diff-c3JjL2h1Z2dpbmdmYWNlX2h1Yi91dGlscy9fdGVsZW1ldHJ5LnB5) | `100.00% <100.00%> (ø)` | |

Help us with your feedback. Take ten seconds to tell us [how you rate us](https://about.codecov.io/nps?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=huggingface). Have a feature suggestion? [Share it here.](https://app.codecov.io/gh/feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=huggingface)

</details>

[:umbrella: View full report at Codecov](https://codecov.io/gh/huggingface/huggingface_hub/pull/1344?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=huggingface).   
:loudspeaker: Do you have feedback about the report comment? [Let us know in this issue](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=huggingface).

dawoodkhan82: @Wauplin any update on when we can expect this to be merged?
Wauplin: @dawoodkhan82 It's tested and reviewed so let's merge it now :)

But I guess your question is more, "when will it be released"? For this question I'm not sure yet. Except if it's urgent I don't think I'll make a new release of `huggingface_hub` in the next 2 weeks. Would that be ok for you. Open to discussion of course :)
dawoodkhan82: @Wauplin Not urgent, but would be nice if it was released sooner. It would help with fully migrating away from segment sooner (which bills us monthly).
julien-c: @dawoodkhan82 but are you going to pin the latest huggingface_hub in gradio then? (just curious)
dawoodkhan82: @julien-c Yeah that's the plan. I think we should also collect analytics using both segment + `huggingface_hub` for the first 2 weeks or so to make sure the transition is smooth + there's no discrepancies. Shouldn't be an issue right? @abidlabs 

Patch:
diff --git a/docs/source/package_reference/environment_variables.mdx b/docs/source/package_reference/environment_variables.mdx
index 17199bd4..7dca6107 100644
--- a/docs/source/package_reference/environment_variables.mdx
+++ b/docs/source/package_reference/environment_variables.mdx
@@ -108,6 +108,13 @@ to disable this warning.
 
 For more details, see [cache limitations](../guides/manage-cache#limitations).
 
+### HF_HUB_DISABLE_TELEMETRY
+
+By default, some data is collected by HF libraries (`transformers`, `datasets`, `gradio`,..) to monitor usage, debug issues and help prioritize features.
+Each library defines its own policy (i.e. which usage to monitor) but the core implementation happens in `huggingface_hub` (see [`send_telemetry`]).
+
+You can set `HF_HUB_DISABLE_TELEMETRY=1` as environment variable to globally disable telemetry.
+
 ### HF_HUB_ENABLE_HF_TRANSFER
 
 Set to `True` to download files from the Hub using `hf_transfer`. It's a Rust-based package
diff --git a/docs/source/package_reference/utilities.mdx b/docs/source/package_reference/utilities.mdx
index 7162d8d4..efc28019 100644
--- a/docs/source/package_reference/utilities.mdx
+++ b/docs/source/package_reference/utilities.mdx
@@ -1,6 +1,6 @@
 # Utilities
 
-## Controlling the logging of `huggingface_hub`
+## Configure logging
 
 The `huggingface_hub` package exposes a `logging` utility to control the logging level of the package itself.
 You can import it as such:
@@ -49,7 +49,7 @@ Using these shouldn't be necessary if you use `huggingface_hub` and you don't mo
 
 [[autodoc]] logging.get_logger
 
-## Handling HTTP errors
+## Handle HTTP errors
 
 `huggingface_hub` defines its own HTTP errors to refine the `HTTPError` raised by
 `requests` with additional information sent back by the server.
@@ -114,9 +114,21 @@ user as possible.
 
 [[autodoc]] huggingface_hub.utils.LocalEntryNotFoundError
 
+## Telemetry
+
+`huggingface_hub` includes an helper to send telemetry data. This information helps us debug issues and prioritize new features.
+Users can disable telemetry collection at any time by setting the `HF_HUB_DISABLE_TELEMETRY=1` environment variable.
+Telemetry is also disabled in offline mode (i.e. when setting HF_HUB_OFFLINE=1).
+
+If you are maintainer of a third-party library, sending telemetry data is as simple as making a call to |`send_telemetry`].
+Data is sent in a separate thread to reduce as much as possible the impact for users.
+
+[[autodoc]] utils.send_telemetry
+
+
 ## Validators
 
-`huggingface_hub` include custom validators to validate method arguments automatically.
+`huggingface_hub` includes custom validators to validate method arguments automatically.
 Validation is inspired by the work done in [Pydantic](https://pydantic-docs.helpmanual.io/)
 to validate type hints but with more limited features.
 
diff --git a/src/huggingface_hub/constants.py b/src/huggingface_hub/constants.py
index 8e95cc04..bd706ff5 100644
--- a/src/huggingface_hub/constants.py
+++ b/src/huggingface_hub/constants.py
@@ -85,7 +85,10 @@ default_assets_cache_path = os.path.join(hf_cache_home, "assets")
 HUGGINGFACE_HUB_CACHE = os.getenv("HUGGINGFACE_HUB_CACHE", default_cache_path)
 HUGGINGFACE_ASSETS_CACHE = os.getenv("HUGGINGFACE_ASSETS_CACHE", default_assets_cache_path)
 
-HF_HUB_OFFLINE = _is_true(os.environ.get("HF_HUB_OFFLINE"))
+HF_HUB_OFFLINE = _is_true(os.environ.get("HF_HUB_OFFLINE") or os.environ.get("TRANSFORMERS_OFFLINE"))
+
+# Opt-out from telemetry requests
+HF_HUB_DISABLE_TELEMETRY = _is_true(os.environ.get("HF_HUB_DISABLE_TELEMETRY") or os.environ.get("DISABLE_TELEMETRY"))
 
 # In the past, token was stored in a hardcoded location
 # `_OLD_HF_TOKEN_PATH` is deprecated and will be removed "at some point".
diff --git a/src/huggingface_hub/utils/__init__.py b/src/huggingface_hub/utils/__init__.py
index 22a177d8..e34f4221 100644
--- a/src/huggingface_hub/utils/__init__.py
+++ b/src/huggingface_hub/utils/__init__.py
@@ -91,3 +91,4 @@ from .tqdm import (
     tqdm,
     tqdm_stream_file,
 )
+from ._telemetry import send_telemetry
diff --git a/src/huggingface_hub/utils/_headers.py b/src/huggingface_hub/utils/_headers.py
index bb4735ab..a89237be 100644
--- a/src/huggingface_hub/utils/_headers.py
+++ b/src/huggingface_hub/utils/_headers.py
@@ -15,7 +15,7 @@
 """Contains utilities to handle headers to send in calls to Huggingface Hub."""
 from typing import Dict, Optional, Union
 
-from ..constants import HF_HUB_DISABLE_IMPLICIT_TOKEN
+from .. import constants
 from ._hf_folder import HfFolder
 from ._runtime import (
     get_fastai_version,
@@ -155,7 +155,7 @@ def get_token_to_send(token: Optional[Union[bool, str]]) -> Optional[str]:
         return cached_token
 
     # Case implicit use of the token is forbidden by env variable
-    if HF_HUB_DISABLE_IMPLICIT_TOKEN:
+    if constants.HF_HUB_DISABLE_IMPLICIT_TOKEN:
         return None
 
     # Otherwise: we use the cached token as the user has not explicitly forbidden it
@@ -204,16 +204,27 @@ def _http_user_agent(
         ua = "unknown/None"
     ua += f"; hf_hub/{get_hf_hub_version()}"
     ua += f"; python/{get_python_version()}"
-    if is_torch_available():
-        ua += f"; torch/{get_torch_version()}"
-    if is_tf_available():
-        ua += f"; tensorflow/{get_tf_version()}"
-    if is_fastai_available():
-        ua += f"; fastai/{get_fastai_version()}"
-    if is_fastcore_available():
-        ua += f"; fastcore/{get_fastcore_version()}"
+
+    if not constants.HF_HUB_DISABLE_TELEMETRY:
+        if is_torch_available():
+            ua += f"; torch/{get_torch_version()}"
+        if is_tf_available():
+            ua += f"; tensorflow/{get_tf_version()}"
+        if is_fastai_available():
+            ua += f"; fastai/{get_fastai_version()}"
+        if is_fastcore_available():
+            ua += f"; fastcore/{get_fastcore_version()}"
+
     if isinstance(user_agent, dict):
         ua += "; " + "; ".join(f"{k}/{v}" for k, v in user_agent.items())
     elif isinstance(user_agent, str):
         ua += "; " + user_agent
-    return ua
+
+    return _deduplicate_user_agent(ua)
+
+
+def _deduplicate_user_agent(user_agent: str) -> str:
+    """Deduplicate redundant information in the generated user-agent."""
+    # Split around ";" > Strip whitespaces > Store as dict keys (ensure unicity) > format back as string
+    # Order is implicitly preserved by dictionary structure (see https://stackoverflow.com/a/53657523).
+    return "; ".join({key.strip(): None for key in user_agent.split(";")}.keys())
diff --git a/src/huggingface_hub/utils/_runtime.py b/src/huggingface_hub/utils/_runtime.py
index b8f55c7b..6699461d 100644
--- a/src/huggingface_hub/utils/_runtime.py
+++ b/src/huggingface_hub/utils/_runtime.py
@@ -259,8 +259,9 @@ def dump_environment_info() -> Dict[str, Any]:
     info["ENDPOINT"] = constants.ENDPOINT
     info["HUGGINGFACE_HUB_CACHE"] = constants.HUGGINGFACE_HUB_CACHE
     info["HUGGINGFACE_ASSETS_CACHE"] = constants.HUGGINGFACE_ASSETS_CACHE
-    info["HF_HUB_OFFLINE"] = constants.HF_HUB_OFFLINE
     info["HF_TOKEN_PATH"] = constants.HF_TOKEN_PATH
+    info["HF_HUB_OFFLINE"] = constants.HF_HUB_OFFLINE
+    info["HF_HUB_DISABLE_TELEMETRY"] = constants.HF_HUB_DISABLE_TELEMETRY
     info["HF_HUB_DISABLE_PROGRESS_BARS"] = constants.HF_HUB_DISABLE_PROGRESS_BARS
     info["HF_HUB_DISABLE_SYMLINKS_WARNING"] = constants.HF_HUB_DISABLE_SYMLINKS_WARNING
     info["HF_HUB_DISABLE_IMPLICIT_TOKEN"] = constants.HF_HUB_DISABLE_IMPLICIT_TOKEN
diff --git a/src/huggingface_hub/utils/_telemetry.py b/src/huggingface_hub/utils/_telemetry.py
new file mode 100644
index 00000000..44bb5cda
--- /dev/null
+++ b/src/huggingface_hub/utils/_telemetry.py
@@ -0,0 +1,120 @@
+from queue import Queue
+from threading import Lock, Thread
+from typing import Dict, Optional, Union
+from urllib.parse import quote
+
+import requests
+
+from .. import constants, logging
+from . import build_hf_headers, hf_raise_for_status
+
+
+logger = logging.get_logger(__name__)
+
+# Telemetry is sent by a separate thread to avoid blocking the main thread.
+# A daemon thread is started once and consume tasks from the _TELEMETRY_QUEUE.
+# If the thread stops for some reason -shouldn't happen-, we restart a new one.
+_TELEMETRY_THREAD: Optional[Thread] = None
+_TELEMETRY_THREAD_LOCK = Lock()  # Lock to avoid starting multiple threads in parallel
+_TELEMETRY_QUEUE: Queue = Queue()
+
+
+def send_telemetry(
+    topic: str,
+    *,
+    library_name: Optional[str] = None,
+    library_version: Optional[str] = None,
+    user_agent: Union[Dict, str, None] = None,
+) -> None:
+    """
+    Sends telemetry that helps tracking usage of different HF libraries.
+
+    This usage data helps us debug issues and prioritize new features. However, we understand that not everyone wants
+    to share additional information, and we respect your privacy. You can disable telemetry collection by setting the
+    `HF_HUB_DISABLE_TELEMETRY=1` as environment variable. Telemetry is also disabled in offline mode (i.e. when setting
+    `HF_HUB_OFFLINE=1`).
+
+    Telemetry collection is run in a separate thread to minimize impact for the user.
+
+    Args:
+        topic (`str`):
+            Name of the topic that is monitored. The topic is directly used to build the URL. If you want to monitor
+            subtopics, just use "/" separation. Examples: "gradio", "transformers/examples",...
+        library_name (`str`, *optional*):
+            The name of the library that is making the HTTP request. Will be added to the user-agent header.
+        library_version (`str`, *optional*):
+            The version of the library that is making the HTTP request. Will be added to the user-agent header.
+        user_agent (`str`, `dict`, *optional*):
+            The user agent info in the form of a dictionary or a single string. It will be completed with information about the installed packages.
+
+    Example:
+    ```py
+    >>> from huggingface_hub.utils import send_telemetry
+
+    # Send telemetry without library information
+    >>> send_telemetry("ping")
+
+    # Send telemetry to subtopic with library information
+    >>> send_telemetry("gradio/local_link", library_name="gradio", library_version="3.22.1")
+
+    # Send telemetry with additional data
+    >>> send_telemetry(
+    ...     topic="examples",
+    ...     library_name="transformers",
+    ...     library_version="4.26.0",
+    ...     user_agent={"pipeline": "text_classification", "framework": "flax"},
+    ... )
+    ```
+    """
+    if constants.HF_HUB_OFFLINE or constants.HF_HUB_DISABLE_TELEMETRY:
+        return
+
+    _start_telemetry_thread()  # starts thread only if doesn't exist yet
+    _TELEMETRY_QUEUE.put(
+        {"topic": topic, "library_name": library_name, "library_version": library_version, "user_agent": user_agent}
+    )
+
+
+def _start_telemetry_thread():
+    """Start a daemon thread to consume tasks from the telemetry queue.
+
+    If the thread is interrupted, start a new one.
+    """
+    with _TELEMETRY_THREAD_LOCK:  # avoid to start multiple threads if called concurrently
+        global _TELEMETRY_THREAD
+        if _TELEMETRY_THREAD is None or not _TELEMETRY_THREAD.is_alive():
+            _TELEMETRY_THREAD = Thread(target=_telemetry_worker, daemon=True)
+            _TELEMETRY_THREAD.start()
+
+
+def _telemetry_worker():
+    """Wait for a task and consume it."""
+    while True:
+        kwargs = _TELEMETRY_QUEUE.get()
+        _send_telemetry_in_thread(**kwargs)
+        _TELEMETRY_QUEUE.task_done()
+
+
+def _send_telemetry_in_thread(
+    topic: str,
+    *,
+    library_name: Optional[str] = None,
+    library_version: Optional[str] = None,
+    user_agent: Union[Dict, str, None] = None,
+) -> None:
+    """Contains the actual data sending data to the Hub."""
+    path = "/".join(quote(part) for part in topic.split("/") if len(part) > 0)
+    try:
+        r = requests.head(
+            f"{constants.ENDPOINT}/api/telemetry/{path}",
+            headers=build_hf_headers(
+                token=False,  # no need to send a token for telemetry
+                library_name=library_name,
+                library_version=library_version,
+                user_agent=user_agent,
+            ),
+        )
+        hf_raise_for_status(r)
+    except Exception as e:
+        # We don't want to error in case of connection errors of any kind.
+        logger.debug(f"Error while sending telemetry: {e}")


Test patch:
diff --git a/tests/test_utils_headers.py b/tests/test_utils_headers.py
index 0573da01..ebebd73d 100644
--- a/tests/test_utils_headers.py
+++ b/tests/test_utils_headers.py
@@ -2,7 +2,7 @@ import unittest
 from unittest.mock import Mock, patch
 
 from huggingface_hub.utils import get_hf_hub_version, get_python_version
-from huggingface_hub.utils._headers import _http_user_agent, build_hf_headers
+from huggingface_hub.utils._headers import _deduplicate_user_agent, _http_user_agent, build_hf_headers
 
 from .testing_utils import handle_injection, handle_injection_in_test
 
@@ -61,14 +61,14 @@ class TestAuthHeadersUtil(unittest.TestCase):
 
     def test_implicit_use_disabled(self, mock_HfFolder: Mock) -> None:
         with patch(  # not as decorator to avoid friction with @handle_injection
-            "huggingface_hub.utils._headers.HF_HUB_DISABLE_IMPLICIT_TOKEN", True
+            "huggingface_hub.constants.HF_HUB_DISABLE_IMPLICIT_TOKEN", True
         ):
             mock_HfFolder().get_token.return_value = FAKE_TOKEN
             self.assertEqual(build_hf_headers(), NO_AUTH_HEADER)  # token is not sent
 
     def test_implicit_use_disabled_but_explicit_use(self, mock_HfFolder: Mock) -> None:
         with patch(  # not as decorator to avoid friction with @handle_injection
-            "huggingface_hub.utils._headers.HF_HUB_DISABLE_IMPLICIT_TOKEN", True
+            "huggingface_hub.constants.HF_HUB_DISABLE_IMPLICIT_TOKEN", True
         ):
             mock_HfFolder().get_token.return_value = FAKE_TOKEN
 
@@ -145,3 +145,15 @@ class TestUserAgentHeadersUtil(unittest.TestCase):
 
     def test_user_agent_with_custom_agent_dict(self) -> None:
         self.assertTrue(self._get_user_agent(user_agent={"a": "b", "c": "d"}).endswith("a/b; c/d"))
+
+    def test_user_agent_deduplicate(self) -> None:
+        self.assertEqual(
+            _deduplicate_user_agent(
+                "python/3.7; python/3.8; hf_hub/0.12; transformers/None; hf_hub/0.12; python/3.7; diffusers/0.12.1"
+            ),
+            # 1. "python" is kept twice with different values
+            # 2. "python/3.7" second occurrence is removed
+            # 3. "hf_hub" second occurrence is removed
+            # 4. order is preserved
+            "python/3.7; python/3.8; hf_hub/0.12; transformers/None; diffusers/0.12.1",
+        )
diff --git a/tests/test_utils_telemetry.py b/tests/test_utils_telemetry.py
new file mode 100644
index 00000000..0c6042b4
--- /dev/null
+++ b/tests/test_utils_telemetry.py
@@ -0,0 +1,78 @@
+import unittest
+from queue import Queue
+from unittest.mock import Mock, patch
+
+from huggingface_hub.utils._telemetry import send_telemetry
+
+from .testing_constants import ENDPOINT_STAGING
+
+
+@patch("huggingface_hub.utils._telemetry.requests.head")
+@patch("huggingface_hub.utils._telemetry._TELEMETRY_QUEUE", new_callable=Queue)
+@patch("huggingface_hub.utils._telemetry._TELEMETRY_THREAD", None)
+class TestSendTelemetry(unittest.TestCase):
+    def test_topic_normal(self, queue: Queue, mock_request: Mock) -> None:
+        send_telemetry(topic="examples")
+        queue.join()  # Wait for the telemetry tasks to be completed
+        mock_request.assert_called_once()
+        self.assertEqual(mock_request.call_args[0][0], f"{ENDPOINT_STAGING}/api/telemetry/examples")
+
+    def test_topic_multiple(self, queue: Queue, mock_request: Mock) -> None:
+        send_telemetry(topic="example1")
+        send_telemetry(topic="example2")
+        send_telemetry(topic="example3")
+        queue.join()  # Wait for the telemetry tasks to be completed
+
+        self.assertEqual(mock_request.call_count, 3)  # 3 calls and order is preserved
+        self.assertEqual(mock_request.call_args_list[0][0][0], f"{ENDPOINT_STAGING}/api/telemetry/example1")
+        self.assertEqual(mock_request.call_args_list[1][0][0], f"{ENDPOINT_STAGING}/api/telemetry/example2")
+        self.assertEqual(mock_request.call_args_list[2][0][0], f"{ENDPOINT_STAGING}/api/telemetry/example3")
+
+    def test_topic_with_subtopic(self, queue: Queue, mock_request: Mock) -> None:
+        send_telemetry(topic="gradio/image/this_one")
+        queue.join()  # Wait for the telemetry tasks to be completed
+        mock_request.assert_called_once()
+        self.assertEqual(mock_request.call_args[0][0], f"{ENDPOINT_STAGING}/api/telemetry/gradio/image/this_one")
+
+    def test_topic_quoted(self, queue: Queue, mock_request: Mock) -> None:
+        send_telemetry(topic="foo bar")
+        queue.join()  # Wait for the telemetry tasks to be completed
+        mock_request.assert_called_once()
+        self.assertEqual(mock_request.call_args[0][0], f"{ENDPOINT_STAGING}/api/telemetry/foo%20bar")
+
+    @patch("huggingface_hub.utils._telemetry.constants.HF_HUB_OFFLINE", True)
+    def test_hub_offline(self, queue: Queue, mock_request: Mock) -> None:
+        send_telemetry(topic="topic")
+        self.assertTrue(queue.empty())  # no tasks
+        mock_request.assert_not_called()
+
+    @patch("huggingface_hub.utils._telemetry.constants.HF_HUB_DISABLE_TELEMETRY", True)
+    def test_telemetry_disabled(self, queue: Queue, mock_request: Mock) -> None:
+        send_telemetry(topic="topic")
+        self.assertTrue(queue.empty())  # no tasks
+        mock_request.assert_not_called()
+
+    @patch("huggingface_hub.utils._telemetry.build_hf_headers")
+    def test_telemetry_use_build_hf_headers(self, mock_headers: Mock, queue: Queue, mock_request: Mock) -> None:
+        send_telemetry(topic="topic")
+        queue.join()  # Wait for the telemetry tasks to be completed
+        mock_request.assert_called_once()
+        mock_headers.assert_called_once()
+        self.assertEqual(mock_request.call_args[1]["headers"], mock_headers.return_value)
+
+
+@patch("huggingface_hub.utils._telemetry.requests.head", side_effect=Exception("whatever"))
+@patch("huggingface_hub.utils._telemetry._TELEMETRY_QUEUE", new_callable=Queue)
+@patch("huggingface_hub.utils._telemetry._TELEMETRY_THREAD", None)
+class TestSendTelemetryConnectionError(unittest.TestCase):
+    def test_telemetry_exception_silenced(self, queue: Queue, mock_request: Mock) -> None:
+        with self.assertLogs(logger="huggingface_hub.utils._telemetry", level="DEBUG") as captured:
+            send_telemetry(topic="topic")
+            queue.join()
+
+        # Assert debug message with traceback for debug purposes
+        self.assertEqual(len(captured.output), 1)
+        self.assertEqual(
+            captured.output[0],
+            "DEBUG:huggingface_hub.utils._telemetry:Error while sending telemetry: whatever",
+        )