diff --git a/pyproject.toml b/pyproject.toml index 1f93992..2b395ba 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,6 +33,7 @@ dev = [ "pytest-asyncio>=0.25.2", "pytest-cov>=6.0.0", "pytest-ordering>=0.6", + "pytest_httpserver >=1.1.3", "ruff==0.9.2", "types-deprecated>=1.2.15.20241117", "types-requests>=2.32.0.20241016", diff --git a/src/speckleifc/main.py b/src/speckleifc/main.py index e3350d3..93dbf7a 100644 --- a/src/speckleifc/main.py +++ b/src/speckleifc/main.py @@ -23,7 +23,6 @@ from specklepy.transports.server import ServerTransport def open_and_convert_file( file_path: str, project: Project, - version_message: str | None, model_ingestion_id: str, client: SpeckleClient, ) -> Version: @@ -41,7 +40,7 @@ def open_and_convert_file( source_data=SourceDataInput( file_name=path.name, file_size_bytes=path.stat().st_size, - source_application_slug="fileimports-ifc", + source_application_slug=metrics.HOST_APP, source_application_version=specklepy_version, ), ) @@ -103,7 +102,14 @@ def open_and_convert_file( custom_properties = {"ui": "dui3", "actionSource": "import"} if project.workspace_id: custom_properties["workspace_id"] = project.workspace_id - metrics.track(metrics.SEND, account, custom_properties, send_sync=True) + + metrics.track( + metrics.SEND, + account, + custom_properties, + send_sync=True, + track_email=True, + ) return version except Exception as e: diff --git a/src/specklepy/api/resources/current/model_ingestion_resource.py b/src/specklepy/api/resources/current/model_ingestion_resource.py index 22ac999..1bc227d 100644 --- a/src/specklepy/api/resources/current/model_ingestion_resource.py +++ b/src/specklepy/api/resources/current/model_ingestion_resource.py @@ -40,11 +40,11 @@ class ModelIngestionResource(CoreResource): metrics.track(metrics.SDK, self.account, {"name": "Ingestion Update"}) return super().requeue(input) - def complete_successfully(self, input: ModelIngestionSuccessInput) -> str: + def complete(self, input: ModelIngestionSuccessInput) -> str: metrics.track(metrics.SDK, self.account, {"name": "Ingestion End"}) return super().complete(input) - def complete_failed(self, input: ModelIngestionFailedInput) -> ModelIngestion: + def fail_with_error(self, input: ModelIngestionFailedInput) -> ModelIngestion: metrics.track(metrics.SDK, self.account, {"name": "Ingestion Error"}) return super().fail_with_error(input) diff --git a/src/specklepy/logging/metrics.py b/src/specklepy/logging/metrics.py index 04cc1d3..a7259b0 100644 --- a/src/specklepy/logging/metrics.py +++ b/src/specklepy/logging/metrics.py @@ -1,19 +1,20 @@ import contextlib import getpass import hashlib +import importlib.metadata import logging import platform import queue import sys import threading -from typing import Any +from typing import Any, Literal import requests from specklepy.core.api.credentials import Account """ -Anonymous telemetry to help us understand how to make a better Speckle. +Lightweight usage telemetry to help us understand how to make a better Speckle. This really helps us to deliver a better open source project and product! """ TRACK = True @@ -22,13 +23,14 @@ HOST_APP_VERSION = f"python {'.'.join(map(str, sys.version_info[:2]))}" PLATFORMS = {"win32": "Windows", "cygwin": "Windows", "darwin": "Mac OS X"} LOG = logging.getLogger(__name__) -METRICS_TRACKER = None +METRICS_TRACKER: "MetricsTracker | None" = None # actions SDK = "SDK Action" CONNECTOR = "Connector Action" RECEIVE = "Receive" SEND = "Send" +ACTIONS = Literal["SDK Action", "Connector Action", "Receive", "Send"] def disable(): @@ -48,15 +50,32 @@ def set_host_app(host_app: str, host_app_version: str | None = None): def track( - action: str, + action: ACTIONS, account: Account | None = None, custom_props: dict | None = None, send_sync: bool = False, + track_email: bool = False, ): + """ + :param action: + :type action: ACTIONS + :param account: + :type account: Account | None + :param custom_props: + :type custom_props: dict | None + :param send_sync: When `True`, the track event is executed synchronously, + and any exceptions will be raised. + When `False`, the track it is deferred to a queue, and any exceptions will be + swallowed and reported as warnings. + :type send_sync: bool + :param track_email: When `True`, the users plain text email address will be included + :type track_email: bool + """ if not TRACK: return tracker = initialise_tracker(account) + event_params: dict[str, Any] = { "event": action, "properties": { @@ -72,6 +91,18 @@ def track( if custom_props: event_params["properties"].update(custom_props) + if track_email: + event_params["properties"]["email"] = tracker.last_email + + try: + specklepy_version = importlib.metadata.version("specklepy") + event_params["properties"]["core_version"] = specklepy_version + except importlib.metadata.PackageNotFoundError: + if send_sync: + raise + else: + LOG.warning("Failed to read specklepy's version number", exc_info=True) + if send_sync: tracker.send_event(event_params) else: @@ -84,7 +115,7 @@ def initialise_tracker(account: Account | None = None) -> "MetricsTracker": METRICS_TRACKER = MetricsTracker() if account: - METRICS_TRACKER.set_last_user(account.userInfo.email) + METRICS_TRACKER.set_last_user_email(account.userInfo.email) METRICS_TRACKER.set_last_server(account.serverInfo.url) return METRICS_TRACKER @@ -103,6 +134,7 @@ class MetricsTracker(metaclass=Singleton): analytics_url: str = "https://analytics.speckle.systems/track?ip=1" analytics_token: str = "acd87c5a50b56df91a795e999812a3a4" last_user: str = "" + last_email: str = "" last_server: str | None = None platform: str @@ -121,17 +153,19 @@ class MetricsTracker(metaclass=Singleton): if node and user: self.last_user = f"@{self.hash(f'{node}-{user}')}" - def set_last_user(self, email: str | None) -> None: + def set_last_user_email(self, email: str | None) -> None: if not email: return self.last_user = f"@{self.hash(email)}" + self.last_email = email def set_last_server(self, server: str | None) -> None: if not server: return self.last_server = self.hash(server) - def hash(self, value: str) -> str: + @staticmethod + def hash(value: str) -> str: inputList = value.lower().split("://") input = inputList[len(inputList) - 1].split("/")[0].split("?")[0] return hashlib.md5(input.encode("utf-8")).hexdigest().upper() diff --git a/tests/integration/test_metrics.py b/tests/integration/test_metrics.py new file mode 100644 index 0000000..477e75e --- /dev/null +++ b/tests/integration/test_metrics.py @@ -0,0 +1,92 @@ +from typing import Any, Callable + +import pytest +from pytest_httpserver import HTTPServer +from requests import HTTPError +from werkzeug import Request, Response + +from specklepy.core.api.client import SpeckleClient +from specklepy.logging import metrics + +PATH = "/" + + +def assert_common_properties(payload: Any) -> None: + assert payload["event"] == "SDK Action" + assert payload["properties"]["token"] == "acd87c5a50b56df91a795e999812a3a4" + assert payload["properties"]["type"] == "action" + assert payload["properties"]["server_id"] + assert payload["properties"]["distinct_id"] + assert payload["properties"]["hostApp"] == "python" + assert payload["properties"]["hostAppVersion"] + assert payload["properties"]["core_version"] + + +def handler(extra_check: Callable[[Any], bool]) -> Callable[[Request], Response]: + def inner(request: Request) -> Response: + json = request.get_json() + payload = json[0] + assert_common_properties(payload) + assert extra_check(payload) + return Response("", 200) + + return inner + + +def test_metrics_track(httpserver: HTTPServer, client: SpeckleClient): + with ScopedMetricsSetup(httpserver.url_for(PATH)) as _: + # Test No email + httpserver.expect_oneshot_request(PATH, "post").respond_with_handler( + handler(lambda payload: "email" not in payload["properties"]) + ) + metrics.track("SDK Action", client.account, None, True, False) + + # Test With email + httpserver.expect_oneshot_request(PATH, "post").respond_with_handler( + handler( + lambda payload: payload["properties"]["email"] + == client.account.userInfo.email + ) + ) + metrics.track("SDK Action", client.account, None, True, True) + + # Test With custom value + httpserver.expect_oneshot_request(PATH, "post").respond_with_handler( + handler( + lambda payload: payload["properties"]["myCustomProp"] == "myCustomValue" + ) + ) + metrics.track( + "SDK Action", client.account, {"myCustomProp": "myCustomValue"}, True, True + ) + + +def test_metrics_errors(httpserver: HTTPServer): + with ScopedMetricsSetup(httpserver.url_for(PATH)) as _: + httpserver.expect_oneshot_request(PATH, "post").respond_with_data("", 400) + + # Expect send_sync == true to mean mean it will raise + with pytest.raises(HTTPError): + metrics.track("SDK Action", send_sync=True) + + # Expect send_sync == false to mean mean it won't + metrics.track("SDK Action") + + +class ScopedMetricsSetup: + """ + Scoped setup and tear down for enabling metrics tracking + """ + + tracker: metrics.MetricsTracker + + def __init__(self, metrics_url: str): + self.tracker = metrics.initialise_tracker() + self.tracker.analytics_url = metrics_url + + def __enter__(self): + metrics.enable() + + def __exit__(self, exc_type, exc_value, traceback): + metrics.disable() + metrics.METRICS_TRACKER = None diff --git a/uv.lock b/uv.lock index a412974..7b6688e 100644 --- a/uv.lock +++ b/uv.lock @@ -1867,6 +1867,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" }, ] +[[package]] +name = "pytest-httpserver" +version = "1.1.3" +source = { registry = "https://pypi.org/simple/" } +dependencies = [ + { name = "werkzeug" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f1/d8/def15ba33bd696dd72dd4562a5287c0cba4d18a591eeb82e0b08ab385afc/pytest_httpserver-1.1.3.tar.gz", hash = "sha256:af819d6b533f84b4680b9416a5b3f67f1df3701f1da54924afd4d6e4ba5917ec", size = 68870, upload-time = "2025-04-10T08:17:15.6Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0d/d2/dfc2f25f3905921c2743c300a48d9494d29032f1389fc142e718d6978fb2/pytest_httpserver-1.1.3-py3-none-any.whl", hash = "sha256:5f84757810233e19e2bb5287f3826a71c97a3740abe3a363af9155c0f82fdbb9", size = 21000, upload-time = "2025-04-10T08:17:13.906Z" }, +] + [[package]] name = "pytest-ordering" version = "0.6" @@ -2222,6 +2234,7 @@ dev = [ { name = "pytest" }, { name = "pytest-asyncio" }, { name = "pytest-cov" }, + { name = "pytest-httpserver" }, { name = "pytest-ordering" }, { name = "ruff" }, { name = "types-deprecated" }, @@ -2259,6 +2272,7 @@ dev = [ { name = "pytest", specifier = ">=8.3.4" }, { name = "pytest-asyncio", specifier = ">=0.25.2" }, { name = "pytest-cov", specifier = ">=6.0.0" }, + { name = "pytest-httpserver", specifier = ">=1.1.3" }, { name = "pytest-ordering", specifier = ">=0.6" }, { name = "ruff", specifier = "==0.9.2" }, { name = "types-deprecated", specifier = ">=1.2.15.20241117" }, @@ -2622,6 +2636,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/47/96/9d5749106ff57629b54360664ae7eb9afd8302fad1680ead385383e33746/websockets-11.0.3-py3-none-any.whl", hash = "sha256:6681ba9e7f8f3b19440921e99efbb40fc89f26cd71bf539e45d8c8a25c976dc6", size = 118056, upload-time = "2023-05-07T14:25:18.508Z" }, ] +[[package]] +name = "werkzeug" +version = "3.1.4" +source = { registry = "https://pypi.org/simple/" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/45/ea/b0f8eeb287f8df9066e56e831c7824ac6bab645dd6c7a8f4b2d767944f9b/werkzeug-3.1.4.tar.gz", hash = "sha256:cd3cd98b1b92dc3b7b3995038826c68097dcb16f9baa63abe35f20eafeb9fe5e", size = 864687, upload-time = "2025-11-29T02:15:22.841Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2f/f9/9e082990c2585c744734f85bec79b5dae5df9c974ffee58fe421652c8e91/werkzeug-3.1.4-py3-none-any.whl", hash = "sha256:2ad50fb9ed09cc3af22c54698351027ace879a0b60a3b5edf5730b2f7d876905", size = 224960, upload-time = "2025-11-29T02:15:21.13Z" }, +] + [[package]] name = "wrapt" version = "2.0.1"