chore(speckleifc): Ifc metrics slug tweaks (#477)
Publish Python Package / test (push) Has been cancelled
Publish Python Package / Build and Publish Python Package (push) Has been cancelled

* ifc metrics

* add http server tests for metrics

* clean up tests

* change back to localhost:3000

* comment

* renamed wrapper for clarity

* fix unrelated model_ingestion
This commit is contained in:
Jedd Morgan
2025-12-09 15:18:21 +00:00
committed by GitHub
parent 8249cd2184
commit ba8c356d82
6 changed files with 171 additions and 12 deletions
+1
View File
@@ -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",
+9 -3
View File
@@ -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)
+41 -7
View File
@@ -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()
+92
View File
@@ -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
Generated
+26
View File
@@ -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"