Skip to content
Merged
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
4 changes: 4 additions & 0 deletions bkit_oauth.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@
def handle_login_task(task: client_tasks.Task):
"""Handles incoming task of type Login. Writes tokens if it finished successfully, logouts the user on error."""
if task.status == "finished":
client_lib.report_event("login_completed")
tasks_queue.add_task(
(
write_tokens,
Expand All @@ -66,6 +67,7 @@ def handle_login_task(task: client_tasks.Task):
)
)
elif task.status == "error":
client_lib.report_event("login_failed", {"message": str(task.message)[:256]})
logout()
reports.add_report(task.message, type="ERROR", details=task.message_detailed)

Expand Down Expand Up @@ -153,6 +155,7 @@ def login(signup: bool, placement: str = "login") -> None:
else:
authorize_url = f"{global_vars.SERVER}{authorize_url}"
authorize_url = paths.url_with_utm(authorize_url, placement)
client_lib.report_event("login_started", {"placement": placement, "signup": signup})
ok = open_new_tab(authorize_url)
bk_logger.info("Login page in browser opened (%s)", ok)

Expand Down Expand Up @@ -292,6 +295,7 @@ def poll(cls, context):
def execute(self, context):
preferences = bpy.context.preferences.addons[__package__].preferences
preferences.login_attempt = False
client_lib.report_event("login_cancelled")
return {"FINISHED"}


Expand Down
19 changes: 19 additions & 0 deletions client_lib.py
Original file line number Diff line number Diff line change
Expand Up @@ -418,6 +418,25 @@ def report_usages(data: dict):
)


def report_event(event: str, data: Optional[dict] = None) -> None:
"""Fire-and-forget telemetry event (e.g. login funnel) via Blendkit-Client.

The Client forwards it to the server with standard headers in the background
and surfaces nothing to the UI.
"""
payload = ensure_minimal_data({"event": event, "data": data or {}})
try:
with requests.Session() as session:
session.post(
f"{get_base_url()}/report_event",
json=payload,
timeout=TIMEOUT,
proxies=NO_PROXIES,
)
except Exception as e: # noqa: BLE001 - telemetry must never break the main flow
bk_logger.debug("report_event %s failed: %s", event, e)


# RATINGS
def get_rating(asset_id: str):
data = ensure_minimal_data({"asset_id": asset_id})
Expand Down
1 change: 1 addition & 0 deletions tests/test.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@
"test_utils",
"test_version_compare",
"test_client_lib",
"test_bkit_oauth",
"test_search",
"test_asset_bar_op",
"test_global_vars",
Expand Down
75 changes: 75 additions & 0 deletions tests/test_bkit_oauth.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,3 +115,78 @@ def test_login_placement_is_tagged(self):

query = parse_qs(urlsplit(open_new_tab.call_args.args[0]).query)
self.assertEqual(query["utm_content"], ["premium_popup"])


class TestLoginTelemetry(unittest.TestCase):
def test_login_reports_started_event_with_placement(self):
with (
mock.patch.object(global_vars, "SERVER", "https://example.com"),
mock.patch.object(bkit_oauth.client_lib, "get_port", return_value="12345"),
mock.patch.object(
bkit_oauth, "generate_pkce_pair", return_value=("verifier", "challenge")
),
mock.patch.object(
bkit_oauth.secrets, "token_urlsafe", return_value="state-token"
),
mock.patch.object(
bkit_oauth, "get_system_id", return_value="000000000000123"
),
mock.patch.object(bkit_oauth.client_lib, "send_oauth_verification_data"),
mock.patch.object(bkit_oauth, "open_new_tab", return_value=True),
mock.patch.object(bkit_oauth.client_lib, "report_event") as report_event,
):
bkit_oauth.login(signup=True, placement="premium_popup")

report_event.assert_called_once_with(
"login_started", {"placement": "premium_popup", "signup": True}
)

def test_cancel_reports_cancelled_event(self):
with mock.patch.object(bkit_oauth.client_lib, "report_event") as report_event:
bpy.ops.wm.blenderkit_login_cancel()

report_event.assert_called_once_with("login_cancelled")

def test_finished_login_task_reports_completed(self):
task = mock.Mock()
task.status = "finished"
task.result = {"access_token": "at", "refresh_token": "rt"}
with (
mock.patch.object(bkit_oauth.tasks_queue, "add_task") as add_task,
mock.patch.object(bkit_oauth.client_lib, "report_event") as report_event,
):
bkit_oauth.handle_login_task(task)

report_event.assert_called_once_with("login_completed")
add_task.assert_called_once()

def test_error_login_task_reports_failed_with_message(self):
task = mock.Mock()
task.status = "error"
task.message = "Server is down"
task.message_detailed = "details"
with (
mock.patch.object(bkit_oauth, "logout") as logout,
mock.patch.object(bkit_oauth.reports, "add_report"),
mock.patch.object(bkit_oauth.client_lib, "report_event") as report_event,
):
bkit_oauth.handle_login_task(task)

report_event.assert_called_once_with(
"login_failed", {"message": "Server is down"}
)
logout.assert_called_once()

def test_token_refresh_does_not_report_login_completed(self):
"""write_tokens is shared with token refresh - the event must live in
handle_login_task only, or every refresh would count as a login."""
with (
mock.patch.object(bkit_oauth, "bpy") as bpy_mock,
mock.patch.object(bkit_oauth.search_price, "clear_price_cache"),
mock.patch.object(bkit_oauth.client_lib, "report_event") as report_event,
):
# below the 4.2 extensions branch, which needs a real repo setup
bpy_mock.app.version = (3, 6, 0)
bkit_oauth.write_tokens("at", "rt", {"expires_in": 3600})

report_event.assert_not_called()
30 changes: 30 additions & 0 deletions tests/test_client_lib.py
Original file line number Diff line number Diff line change
Expand Up @@ -605,3 +605,33 @@ def test_non_default_port_names_client_log(self):
):
log_path = client_lib.get_client_log_path()
self.assertTrue(log_path.endswith("client-49452.log"))


class TestReportEvent(unittest.TestCase):
def setUp(self):
self._saved_ports = global_vars.CLIENT_PORTS
self._saved_version = global_vars.CLIENT_VERSION
global_vars.CLIENT_PORTS = ["62485"]
global_vars.CLIENT_VERSION = "v1.12.0"

def tearDown(self):
global_vars.CLIENT_PORTS = self._saved_ports
global_vars.CLIENT_VERSION = self._saved_version

def test_posts_event_to_client(self):
with mock.patch.object(client_lib.requests, "Session") as session_cls:
session = session_cls.return_value.__enter__.return_value
client_lib.report_event("login_started", {"placement": "login_panel"})
url = session.post.call_args.args[0]
payload = session.post.call_args.kwargs["json"]
self.assertEqual(url, "http://127.0.0.1:62485/v1.12/report_event")
self.assertEqual(payload["event"], "login_started")
self.assertEqual(payload["data"], {"placement": "login_panel"})
self.assertIn("app_id", payload)
self.assertIn("addon_version", payload)

def test_never_raises(self):
with mock.patch.object(
client_lib.requests, "Session", side_effect=OSError("no client")
):
client_lib.report_event("login_cancelled")
Loading