Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ba8c356d82 |
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user