Skip to content

Commit 6af1b07

Browse files
authored
Server telemetry reporting (#814)
feat: server telemetry reporting
1 parent 68dd6a6 commit 6af1b07

File tree

5 files changed

+168
-3
lines changed

5 files changed

+168
-3
lines changed

src/xpk/core/telemetry.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,68 @@
1818
import uuid
1919
import json
2020
import time
21+
import sys
22+
import importlib
23+
import subprocess
24+
import tempfile
2125
from enum import Enum
26+
from typing import Any
2227
from dataclasses import dataclass
2328
from .config import xpk_config, CLIENT_ID_KEY, __version__ as xpk_version
2429
from ..utils.execution_context import is_dry_run
30+
from ..utils.user_agent import get_user_agent
31+
32+
33+
def send_clearcut_payload(data: str, wait_to_complete: bool = False) -> None:
34+
"""Sends payload to clearcut endpoint."""
35+
try:
36+
file_path = _store_payload_in_temp_file(data)
37+
_flush_temp_file_to_clearcut(file_path, wait_to_complete)
38+
except Exception: # pylint: disable=broad-exception-caught
39+
pass
40+
41+
42+
def _store_payload_in_temp_file(data: str) -> str:
43+
with tempfile.NamedTemporaryFile(
44+
mode="w", delete=False, encoding="utf-8"
45+
) as file:
46+
json.dump(
47+
{
48+
"data": data,
49+
"url": "https://play.googleapis.com/log",
50+
"params": {"format": "json_proto"},
51+
"headers": {"User-Agent": get_user_agent()},
52+
"method": "POST",
53+
},
54+
file,
55+
)
56+
return file.name
57+
58+
59+
def _flush_temp_file_to_clearcut(
60+
file_path: str, wait_to_complete: bool
61+
) -> None:
62+
with importlib.resources.path("xpk", "telemetry_uploader.py") as path:
63+
kwargs: dict[str, Any] = {}
64+
if sys.platform == "win32":
65+
kwargs["creationflags"] = (
66+
subprocess.DETACHED_PROCESS | subprocess.CREATE_NO_WINDOW
67+
)
68+
else:
69+
kwargs["start_new_session"] = True
70+
71+
process = subprocess.Popen(
72+
args=[
73+
sys.executable,
74+
str(path),
75+
file_path,
76+
],
77+
stdout=sys.stdout if wait_to_complete else subprocess.DEVNULL,
78+
stderr=sys.stderr if wait_to_complete else subprocess.DEVNULL,
79+
**kwargs,
80+
)
81+
if wait_to_complete:
82+
process.wait()
2583

2684

2785
def ensure_client_id() -> str:

src/xpk/main.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@
3737

3838
from .parser.core import set_parser
3939
from .core.updates import print_xpk_hello
40-
from .core.telemetry import MetricsCollector
40+
from .core.telemetry import MetricsCollector, send_clearcut_payload
4141
from .utils.feature_flags import FeatureFlags
4242
from .utils.console import xpk_print, exit_code_to_int
4343
from .utils.execution_context import set_context
@@ -90,8 +90,7 @@ def main() -> None:
9090
raise
9191
finally:
9292
if FeatureFlags.TELEMETRY_ENABLED:
93-
# TODO(@scaliby): Flush to server instead of a console
94-
xpk_print(MetricsCollector.flush())
93+
send_clearcut_payload(MetricsCollector.flush())
9594

9695

9796
if __name__ == '__main__':

src/xpk/telemetry_uploader.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
"""
2+
Copyright 2025 Google LLC
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
https://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
"""
16+
17+
import sys
18+
import os
19+
import requests
20+
import json
21+
22+
file_path = sys.argv[1]
23+
if os.path.exists(file_path):
24+
with open(file_path, mode="r", encoding="utf-8") as file:
25+
kwargs = json.load(file)
26+
response = requests.request(**kwargs)
27+
print(f"Telemetry upload finished with {response.status_code} status code")
28+
29+
os.remove(file_path)

src/xpk/utils/user_agent.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
"""
2+
Copyright 2025 Google LLC
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
https://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
"""
16+
17+
import platform
18+
from ..core.config import __version__ as xpk_version
19+
20+
21+
def get_user_agent() -> str:
22+
return f'XPK/{xpk_version} ({_get_user_agent_platform()})'
23+
24+
25+
def _get_user_agent_platform() -> str:
26+
system = platform.system().lower()
27+
if system == 'windows':
28+
return f'Windows NT {platform.version()}'
29+
elif system == 'linux':
30+
return f'Linux; {platform.machine()}'
31+
elif system == 'darwin':
32+
version, _, arch = platform.mac_ver()
33+
return f'Macintosh; {arch} Mac OS X {version}'
34+
else:
35+
return ''

src/xpk/utils/user_agent_test.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
"""
2+
Copyright 2025 Google LLC
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
https://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
"""
16+
17+
from .user_agent import get_user_agent
18+
19+
20+
def test_get_user_agent_returns_correct_value_for_windows(mocker):
21+
mocker.patch('xpk.utils.user_agent.xpk_version', 'v1.0.0')
22+
mocker.patch('platform.system', return_value='Windows')
23+
mocker.patch('platform.version', return_value='10.0')
24+
assert get_user_agent() == 'XPK/v1.0.0 (Windows NT 10.0)'
25+
26+
27+
def test_get_user_agent_returns_correct_value_for_linux(mocker):
28+
mocker.patch('xpk.utils.user_agent.xpk_version', 'v1.0.0')
29+
mocker.patch('platform.system', return_value='Linux')
30+
mocker.patch('platform.machine', return_value='x86_64')
31+
assert get_user_agent() == 'XPK/v1.0.0 (Linux; x86_64)'
32+
33+
34+
def test_get_user_agent_returns_correct_value_for_darwin(mocker):
35+
mocker.patch('xpk.utils.user_agent.xpk_version', 'v1.0.0')
36+
mocker.patch('platform.system', return_value='Darwin')
37+
mocker.patch('platform.mac_ver', return_value=('10.15', '', 'x86_64'))
38+
assert get_user_agent() == 'XPK/v1.0.0 (Macintosh; x86_64 Mac OS X 10.15)'
39+
40+
41+
def test_get_user_agent_returns_correct_value_for_unknown(mocker):
42+
mocker.patch('xpk.utils.user_agent.xpk_version', 'v1.0.0')
43+
mocker.patch('platform.system', return_value='Unknown')
44+
assert get_user_agent() == 'XPK/v1.0.0 ()'

0 commit comments

Comments
 (0)