Skip to content

Commit 26f3d45

Browse files
lkang172copybara-github
authored andcommitted
feat: Add telemetry consent configuration endpoints and local writing utility
- Establish read_telemetry_consent and write_telemetry_consent utilities to store opt-in status locally in ~/.adk/config.json. - Implement GET and POST '/config/telemetry' FastAPI endpoints inside dev_server.py. - Prevent CSRF/XSRF forgery by requiring the 'x-adk-telemetry-request: true' header on all POST requests. - Add unit tests verifying route access permissions and json persistence. Co-authored-by: Lucas Kang <lucaskang@google.com> PiperOrigin-RevId: 952873455
1 parent 021f6f6 commit 26f3d45

6 files changed

Lines changed: 246 additions & 7 deletions

File tree

src/google/adk/cli/api_server.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@
8080
from ..runners import Runner
8181
from ..sessions.base_session_service import BaseSessionService
8282
from ..sessions.session import Session
83+
from ..utils._telemetry_config import read_telemetry_consent
8384
from ..utils.agent_info import AgentInfo
8485
from ..utils.agent_info import get_agents_dict
8586
from ..utils.context_utils import Aclosing
@@ -904,6 +905,9 @@ def _setup_runtime_config(self, web_assets_dir: str):
904905
runtime_config_path,
905906
)
906907
runtime_config["backendUrl"] = self.url_prefix if self.url_prefix else ""
908+
# Inject telemetry consent on bootstrapping to avoid an extra API call
909+
# when loading the UI.
910+
runtime_config["telemetry"] = read_telemetry_consent()
907911

908912
# Set custom logo config.
909913
if self.logo_text or self.logo_image_url:
Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
{
2-
"backendUrl": ""
2+
"backendUrl": "",
3+
"telemetry": null
34
}

src/google/adk/cli/dev_server.py

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636

3737
from fastapi import FastAPI
3838
from fastapi import HTTPException
39+
from fastapi import Request as FastAPIRequest
3940
from fastapi import UploadFile
4041
from fastapi.responses import FileResponse
4142
from fastapi.responses import PlainTextResponse
@@ -59,6 +60,8 @@
5960
from ..evaluation.eval_metrics import MetricInfo
6061
from ..evaluation.eval_result import EvalSetResult
6162
from ..evaluation.eval_set import EvalSet
63+
from ..utils._telemetry_config import read_telemetry_consent
64+
from ..utils._telemetry_config import write_telemetry_consent
6265
from .api_server import ApiServer
6366

6467
NESTED_APP_SEPARATOR = "."
@@ -152,6 +155,12 @@ class ListMetricsInfoResponse(common.BaseModel):
152155
metrics_info: list[MetricInfo]
153156

154157

158+
class TelemetryConsentRequest(common.BaseModel):
159+
"""Request body for setting the telemetry consent configuration."""
160+
161+
telemetry: bool
162+
163+
155164
class DevServer(ApiServer):
156165
"""Development server that extends ApiServer with dev-only endpoints.
157166
@@ -204,10 +213,28 @@ def _register_dev_endpoints(
204213
):
205214
"""Register all development-only endpoints.
206215
207-
This includes debug, evaluation, and graph visualization endpoints.
208-
These endpoints should NOT be exposed in production deployments.
216+
This includes debug, evaluation, graph visualization, and telemetry consent
217+
endpoints. These endpoints should NOT be exposed in production deployments.
209218
"""
210219

220+
@app.get("/config/telemetry")
221+
async def get_telemetry_consent() -> dict[str, Any]:
222+
"""Gets the user configuration for telemetry consent."""
223+
return {"telemetry": read_telemetry_consent()}
224+
225+
@app.post("/config/telemetry")
226+
async def set_telemetry_consent(
227+
req: TelemetryConsentRequest, request: FastAPIRequest
228+
) -> dict[str, Any]:
229+
"""Sets the user configuration for telemetry consent."""
230+
if request.headers.get("x-adk-telemetry-request") != "true":
231+
raise HTTPException(
232+
status_code=400,
233+
detail="Forbidden: missing required security header",
234+
)
235+
write_telemetry_consent(req.telemetry)
236+
return {"telemetry": req.telemetry}
237+
211238
# Import needed for eval endpoints
212239
from ..evaluation.constants import MISSING_EVAL_DEPENDENCIES_MESSAGE
213240

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
# Copyright 2026 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
"""Telemetry user consent configuration utilities."""
16+
17+
from __future__ import annotations
18+
19+
import json
20+
import logging
21+
import pathlib
22+
import typing
23+
24+
logger = logging.getLogger("google_adk." + __name__)
25+
26+
27+
def get_user_config_path() -> pathlib.Path:
28+
"""Returns the path to the ADK global config file."""
29+
return pathlib.Path.home() / ".adk" / "config.json"
30+
31+
32+
def read_telemetry_consent() -> typing.Optional[bool]:
33+
"""Reads the telemetry consent status from local config (config.json).
34+
35+
Returns:
36+
True if opted-in, False if opted-out, and None if no explicit
37+
preference has been recorded yet or if there is an error reading
38+
the config.
39+
"""
40+
path = get_user_config_path()
41+
if not path.exists():
42+
return None
43+
try:
44+
with open(path, "r", encoding="utf-8") as f:
45+
config = json.load(f)
46+
val = config.get("telemetry", None)
47+
if isinstance(val, bool):
48+
return val
49+
return None
50+
except Exception as e:
51+
logger.warning("Failed to read telemetry config from %s: %s", path, e)
52+
return None
53+
54+
55+
def write_telemetry_consent(enabled: bool) -> None:
56+
"""Writes the telemetry consent status to local config (config.json)."""
57+
path = get_user_config_path()
58+
try:
59+
path.parent.mkdir(parents=True, exist_ok=True)
60+
config = {}
61+
if path.exists():
62+
try:
63+
with open(path, "r", encoding="utf-8") as f:
64+
config = json.load(f)
65+
if not isinstance(config, dict):
66+
config = {}
67+
except Exception:
68+
# If config parsing fails, start with an empty dictionary
69+
config = {}
70+
config["telemetry"] = enabled
71+
with open(path, "w", encoding="utf-8") as f:
72+
json.dump(config, f, indent=2)
73+
f.write("\n")
74+
except Exception as e:
75+
logger.error("Failed to write telemetry config to %s: %s", path, e)
76+
raise

tests/unittests/cli/test_fast_api.py

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -617,8 +617,8 @@ def test_agent_with_bigquery_analytics_plugin(
617617
os.path,
618618
"exists",
619619
autospec=True,
620-
side_effect=lambda p: p.endswith("plugins.yaml")
621-
or p.endswith("root_agent.yaml"),
620+
side_effect=lambda p: str(p).endswith("plugins.yaml")
621+
or str(p).endswith("root_agent.yaml"),
622622
),
623623
):
624624
from google.adk.cli.adk_web_server import AdkWebServer
@@ -2862,6 +2862,35 @@ def test_version_endpoint(test_app):
28622862
assert "language_version" in data
28632863

28642864

2865+
def test_telemetry_get_endpoint(test_app):
2866+
"""Test the GET telemetry consent endpoint."""
2867+
with patch(
2868+
"google.adk.cli.dev_server.read_telemetry_consent", return_value=True
2869+
):
2870+
response = test_app.get("/config/telemetry")
2871+
assert response.status_code == 200
2872+
assert response.json() == {"telemetry": True}
2873+
2874+
2875+
def test_telemetry_post_endpoint_success(test_app):
2876+
"""Test the POST telemetry consent endpoint with required header."""
2877+
with patch("google.adk.cli.dev_server.write_telemetry_consent") as mock_write:
2878+
headers = {"x-adk-telemetry-request": "true"}
2879+
response = test_app.post(
2880+
"/config/telemetry", json={"telemetry": True}, headers=headers
2881+
)
2882+
assert response.status_code == 200
2883+
assert response.json() == {"telemetry": True}
2884+
mock_write.assert_called_once_with(True)
2885+
2886+
2887+
def test_telemetry_post_endpoint_missing_header(test_app):
2888+
"""Test the POST telemetry consent endpoint without required header."""
2889+
response = test_app.post("/config/telemetry", json={"telemetry": True})
2890+
assert response.status_code == 400
2891+
assert "Forbidden: missing required security header" in response.text
2892+
2893+
28652894
@pytest.fixture
28662895
def test_app_auto_session(
28672896
mock_session_service,
@@ -3028,8 +3057,8 @@ async def run_async_capture(
30283057
os.path,
30293058
"exists",
30303059
autospec=True,
3031-
side_effect=lambda p: "yaml_app" in p
3032-
and p.endswith("root_agent.yaml"),
3060+
side_effect=lambda p: "yaml_app" in str(p)
3061+
and str(p).endswith("root_agent.yaml"),
30333062
),
30343063
):
30353064
app = get_fast_api_app(
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
# Copyright 2026 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
"""Tests for telemetry_config.py."""
16+
17+
import json
18+
import pathlib
19+
import unittest.mock
20+
21+
from google.adk.utils import _telemetry_config as telemetry_config
22+
import pytest
23+
24+
25+
def test_get_user_config_path():
26+
path = telemetry_config.get_user_config_path()
27+
assert isinstance(path, pathlib.Path)
28+
assert path.name == "config.json"
29+
assert path.parent.name == ".adk"
30+
31+
32+
def test_read_telemetry_consent_not_exists(tmp_path):
33+
config_file = tmp_path / "config.json"
34+
with unittest.mock.patch.object(
35+
telemetry_config, "get_user_config_path", return_value=config_file
36+
):
37+
assert telemetry_config.read_telemetry_consent() is None
38+
39+
40+
def test_read_telemetry_consent_exists_true(tmp_path):
41+
config_file = tmp_path / "config.json"
42+
config_file.parent.mkdir(parents=True, exist_ok=True)
43+
with open(config_file, "w", encoding="utf-8") as f:
44+
json.dump({"telemetry": True}, f)
45+
46+
with unittest.mock.patch.object(
47+
telemetry_config, "get_user_config_path", return_value=config_file
48+
):
49+
assert telemetry_config.read_telemetry_consent() is True
50+
51+
52+
def test_read_telemetry_consent_exists_false(tmp_path):
53+
config_file = tmp_path / "config.json"
54+
config_file.parent.mkdir(parents=True, exist_ok=True)
55+
with open(config_file, "w", encoding="utf-8") as f:
56+
json.dump({"telemetry": False}, f)
57+
58+
with unittest.mock.patch.object(
59+
telemetry_config, "get_user_config_path", return_value=config_file
60+
):
61+
assert telemetry_config.read_telemetry_consent() is False
62+
63+
64+
def test_read_telemetry_consent_invalid_json(tmp_path):
65+
config_file = tmp_path / "config.json"
66+
config_file.parent.mkdir(parents=True, exist_ok=True)
67+
with open(config_file, "w", encoding="utf-8") as f:
68+
f.write("not raw json data")
69+
70+
with unittest.mock.patch.object(
71+
telemetry_config, "get_user_config_path", return_value=config_file
72+
):
73+
assert telemetry_config.read_telemetry_consent() is None
74+
75+
76+
def test_write_telemetry_consent(tmp_path):
77+
config_file = tmp_path / "config.json"
78+
with unittest.mock.patch.object(
79+
telemetry_config, "get_user_config_path", return_value=config_file
80+
):
81+
telemetry_config.write_telemetry_consent(True)
82+
assert telemetry_config.read_telemetry_consent() is True
83+
84+
# Overwrite
85+
telemetry_config.write_telemetry_consent(False)
86+
assert telemetry_config.read_telemetry_consent() is False
87+
88+
with open(config_file, "r", encoding="utf-8") as f:
89+
data = json.load(f)
90+
assert data == {"telemetry": False}
91+
92+
93+
def test_write_telemetry_consent_raises_on_error(tmp_path):
94+
config_file = tmp_path / "config.json"
95+
with unittest.mock.patch.object(
96+
telemetry_config, "get_user_config_path", return_value=config_file
97+
):
98+
with unittest.mock.patch.object(
99+
pathlib.Path, "mkdir", side_effect=OSError("Permission denied")
100+
):
101+
with pytest.raises(OSError):
102+
telemetry_config.write_telemetry_consent(True)

0 commit comments

Comments
 (0)