Skip to content

Commit 4ccc6be

Browse files
wukathcopybara-github
authored andcommitted
feat: add express mode telemetry logging for ADK CLI onboarding
Track user choices (e.g. CREATE_EXPRESS, MANUAL_PROJECT, ABANDON) during Express Mode onboarding in ADK CLI telemetry logs. - Added express_mode_action field to CliCommandRun proto schema. - Recorded express_mode_action in MetricsCollector and forwarded from Click context metadata during command execution. - Added unit tests for express_mode_action serialization. - Updated Clearcut route test case ADK_CLI_basic.textpb. Co-authored-by: Kathy Wu <wukathy@google.com> PiperOrigin-RevId: 962278596
1 parent 6930be4 commit 4ccc6be

6 files changed

Lines changed: 179 additions & 0 deletions

File tree

src/google/adk/cli/_telemetry/_metrics_collector.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,7 @@ def record_command_run(
196196
exit_code: int = 0,
197197
duration_ms: int = 0,
198198
exception_type: str = "",
199+
express_mode_action: str = "",
199200
) -> None:
200201
"""Records a command execution and safely appends to local disk queue."""
201202
with self._lock:
@@ -218,6 +219,10 @@ def record_command_run(
218219
if exception_type:
219220
# Enforce string length limit on exception type name
220221
command_run["exception_type"] = exception_type[:_MAX_EXCEPTION_LENGTH]
222+
if express_mode_action:
223+
command_run["express_mode_action"] = express_mode_action[
224+
:_MAX_STRING_LENGTH
225+
]
221226

222227
source_extension = {
223228
"client_session_id": self._session_id,

src/google/adk/cli/cli_tools_click.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -345,6 +345,7 @@ def invoke(self, ctx: click.Context) -> Any:
345345
exit_code=exit_code,
346346
duration_ms=int((time.monotonic() - start_time) * 1000),
347347
exception_type=exception_type,
348+
express_mode_action=ctx.meta.get("express_mode_action", ""),
348349
)
349350
except Exception: # pylint: disable=broad-except
350351
# Failsafe: telemetry errors must never crash the CLI

src/google/adk/cli/utils/_onboarding.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,16 @@ def prompt_for_google_api_key(
155155
return google_api_key
156156

157157

158+
def _record_express_action(action_name: str) -> None:
159+
"""Records the onboarding choice for the CLI telemetry event."""
160+
# `Context.meta` is one dict shared by reference with every ancestor context,
161+
# so writing it here is enough for the root `TelemetryGroup` context to read
162+
# it back when the command finishes.
163+
ctx = click.get_current_context(silent=True)
164+
if ctx is not None:
165+
ctx.meta["express_mode_action"] = action_name
166+
167+
158168
def handle_login_with_google() -> VertexAIAuth | ExpressModeAuth:
159169
"""Handles the "Login with Google" flow."""
160170
if not gcp_utils.check_adc():
@@ -177,6 +187,7 @@ def handle_login_with_google() -> VertexAIAuth | ExpressModeAuth:
177187
region = express_project.get("region", "us-central1")
178188
if project_id:
179189
click.secho(f"Using existing Express project: {project_id}", fg="green")
190+
_record_express_action("EXISTING_EXPRESS")
180191
return ExpressModeAuth(
181192
api_key=api_key, project_id=project_id, region=region
182193
)
@@ -199,6 +210,7 @@ def handle_login_with_google() -> VertexAIAuth | ExpressModeAuth:
199210
type=click.IntRange(0, len(projects)),
200211
)
201212
if project_index == 0:
213+
_record_express_action("MANUAL_PROJECT")
202214
selected_project_id = prompt_for_google_cloud(None)
203215
else:
204216
selected_project_id = projects[project_index - 1][0]
@@ -220,9 +232,11 @@ def handle_login_with_google() -> VertexAIAuth | ExpressModeAuth:
220232
)
221233

222234
if action == "3":
235+
_record_express_action("ABANDON")
223236
raise click.Abort()
224237

225238
if action == "1":
239+
_record_express_action("MANUAL_PROJECT")
226240
google_cloud_project = prompt_for_google_cloud(None)
227241
google_cloud_region = prompt_for_google_cloud_region(None)
228242
return VertexAIAuth(
@@ -278,10 +292,12 @@ def handle_login_with_google() -> VertexAIAuth | ExpressModeAuth:
278292
click.secho(
279293
"Failed to unset project. Please do it manually.", fg="red"
280294
)
295+
_record_express_action("CREATE_EXPRESS")
281296
return ExpressModeAuth(
282297
api_key=api_key, project_id=project_id, region=region
283298
)
284299

300+
_record_express_action("ABANDON")
285301
click.secho(_NOT_ELIGIBLE_MSG, fg="red")
286302
raise click.Abort()
287303

tests/unittests/cli/_telemetry/test_metrics_collector.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,7 @@ def test_record_command_run(self):
106106
exit_code=0,
107107
duration_ms=450,
108108
exception_type="",
109+
express_mode_action="CREATE_EXPRESS",
109110
)
110111

111112
# Verify it's written in queue file
@@ -121,6 +122,9 @@ def test_record_command_run(self):
121122
self.assertEqual(source["command_run"]["subcommand"], "create")
122123
self.assertEqual(source["command_run"]["exit_code"], 0)
123124
self.assertEqual(source["command_run"]["duration_ms"], 450)
125+
self.assertEqual(
126+
source["command_run"]["express_mode_action"], "CREATE_EXPRESS"
127+
)
124128
self.assertEqual(
125129
source["command_run"]["flags"],
126130
["--debug", "--project", "-v", "--user"],

tests/unittests/cli/utils/test_cli_create.py

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -558,3 +558,105 @@ def test_get_gcp_region_from_gcloud_fail(
558558
),
559559
)
560560
assert _onboarding.get_gcp_region_from_gcloud() == ""
561+
562+
563+
# express_mode_action telemetry
564+
def _onboard_and_get_root_meta() -> Dict[str, Any]:
565+
"""Runs onboarding under a nested context and returns the *root* context meta.
566+
567+
`TelemetryGroup` reads `express_mode_action` off the root context, so the
568+
assertion has to be made there rather than on the subcommand context the
569+
onboarding code happens to run under.
570+
"""
571+
root_ctx = click.Context(click.Command("adk"))
572+
sub_ctx = click.Context(click.Command("create"), parent=root_ctx)
573+
with root_ctx, sub_ctx:
574+
try:
575+
_onboarding.handle_login_with_google()
576+
except click.Abort:
577+
pass
578+
return root_ctx.meta
579+
580+
581+
def test_express_action_recorded_for_existing_express(
582+
monkeypatch: pytest.MonkeyPatch,
583+
) -> None:
584+
"""Reusing an existing Express project records EXISTING_EXPRESS."""
585+
monkeypatch.setattr(gcp_utils, "check_adc", lambda: True)
586+
monkeypatch.setattr(
587+
gcp_utils,
588+
"retrieve_express_project",
589+
lambda: {"api_key": "key", "project_id": "proj", "region": "us-central1"},
590+
)
591+
592+
meta = _onboard_and_get_root_meta()
593+
assert meta.get("express_mode_action") == "EXISTING_EXPRESS"
594+
595+
596+
def test_express_action_recorded_for_manual_project(
597+
monkeypatch: pytest.MonkeyPatch,
598+
) -> None:
599+
"""Entering a project ID by hand records MANUAL_PROJECT."""
600+
monkeypatch.setattr(gcp_utils, "check_adc", lambda: True)
601+
monkeypatch.setattr(gcp_utils, "retrieve_express_project", lambda: None)
602+
monkeypatch.setattr(gcp_utils, "list_gcp_projects", lambda limit: [])
603+
prompts = iter(["1", "test-proj", "us-east1"])
604+
monkeypatch.setattr(click, "prompt", lambda *a, **k: next(prompts))
605+
606+
meta = _onboard_and_get_root_meta()
607+
assert meta.get("express_mode_action") == "MANUAL_PROJECT"
608+
609+
610+
def test_express_action_recorded_for_manual_project_from_list(
611+
monkeypatch: pytest.MonkeyPatch,
612+
) -> None:
613+
"""Opting out of the project list to type an ID records MANUAL_PROJECT."""
614+
monkeypatch.setattr(gcp_utils, "check_adc", lambda: True)
615+
monkeypatch.setattr(gcp_utils, "retrieve_express_project", lambda: None)
616+
monkeypatch.setattr(
617+
gcp_utils, "list_gcp_projects", lambda limit: [("p1", "Project 1")]
618+
)
619+
prompts = iter([0, "manual-proj", "us-east1"])
620+
monkeypatch.setattr(click, "prompt", lambda *a, **k: next(prompts))
621+
622+
meta = _onboard_and_get_root_meta()
623+
assert meta.get("express_mode_action") == "MANUAL_PROJECT"
624+
625+
626+
def test_express_action_recorded_for_create_express(
627+
monkeypatch: pytest.MonkeyPatch,
628+
) -> None:
629+
"""Signing up for a new Express project records CREATE_EXPRESS."""
630+
monkeypatch.setattr(gcp_utils, "check_adc", lambda: True)
631+
monkeypatch.setattr(gcp_utils, "retrieve_express_project", lambda: None)
632+
monkeypatch.setattr(gcp_utils, "list_gcp_projects", lambda limit: [])
633+
monkeypatch.setattr(gcp_utils, "check_express_eligibility", lambda: True)
634+
monkeypatch.setattr(click, "confirm", lambda *a, **k: True)
635+
prompts = iter(["2", "1"])
636+
monkeypatch.setattr(click, "prompt", lambda *a, **k: next(prompts))
637+
monkeypatch.setattr(
638+
gcp_utils,
639+
"sign_up_express",
640+
lambda location="us-central1": {
641+
"api_key": "new-key",
642+
"project_id": "new-proj",
643+
"region": location,
644+
},
645+
)
646+
monkeypatch.setattr(_onboarding, "get_gcp_project_from_gcloud", lambda: "")
647+
648+
meta = _onboard_and_get_root_meta()
649+
assert meta.get("express_mode_action") == "CREATE_EXPRESS"
650+
651+
652+
def test_express_action_recorded_for_abandon(
653+
monkeypatch: pytest.MonkeyPatch,
654+
) -> None:
655+
"""Choosing to abandon onboarding records ABANDON."""
656+
monkeypatch.setattr(gcp_utils, "check_adc", lambda: True)
657+
monkeypatch.setattr(gcp_utils, "retrieve_express_project", lambda: None)
658+
monkeypatch.setattr(gcp_utils, "list_gcp_projects", lambda limit: [])
659+
monkeypatch.setattr(click, "prompt", lambda *a, **k: "3")
660+
661+
meta = _onboard_and_get_root_meta()
662+
assert meta.get("express_mode_action") == "ABANDON"

tests/unittests/cli/utils/test_cli_tools_click.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
from google.adk.agents.base_agent import BaseAgent
3737
from google.adk.agents.run_config import StreamingMode
3838
from google.adk.cli import cli_tools_click
39+
from google.adk.cli.utils import gcp_utils
3940
from google.adk.evaluation.eval_case import EvalCase
4041
from google.adk.evaluation.eval_set import EvalSet
4142
from google.adk.evaluation.local_eval_set_results_manager import LocalEvalSetResultsManager
@@ -263,6 +264,56 @@ def test_cli_telemetry_captures_subcommand_flags(
263264
assert "<app_name>" in source["command_run"]["flags"]
264265

265266

267+
def test_cli_telemetry_records_express_mode_action(
268+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
269+
) -> None:
270+
"""An onboarding choice must reach the logged command_run.
271+
272+
This is the only test covering the hand-off as a whole: `_onboarding` writes
273+
the action into the Click context and `TelemetryGroup` reads it back out. A
274+
typo in the meta key on either side passes every other test in the suite.
275+
"""
276+
monkeypatch.setattr(
277+
"google.adk.cli.cli_tools_click.read_telemetry_consent",
278+
lambda: True,
279+
)
280+
monkeypatch.setattr(
281+
"google.adk.cli._telemetry._metrics_collector"
282+
".MetricsCollector._is_rate_limited",
283+
lambda: True,
284+
)
285+
temp_queue = tmp_path / "telemetry_queue.jsonl"
286+
monkeypatch.setattr(
287+
"google.adk.cli._telemetry._constants.QUEUE_FILE",
288+
str(temp_queue),
289+
)
290+
monkeypatch.setattr(
291+
"google.adk.cli._telemetry._constants.TELEMETRY_SESSIONS_DIR",
292+
str(tmp_path / "telemetry_sessions"),
293+
)
294+
295+
# Drive `create` into the "3. Login with Google" branch, which finds an
296+
# existing Express project and records EXISTING_EXPRESS.
297+
monkeypatch.setattr(gcp_utils, "check_adc", lambda: True)
298+
monkeypatch.setattr(
299+
gcp_utils,
300+
"retrieve_express_project",
301+
lambda: {"api_key": "key", "project_id": "proj", "region": "us-central1"},
302+
)
303+
304+
runner = CliRunner()
305+
result = runner.invoke(
306+
cli_tools_click.main,
307+
["create", "--model", "gemini-2.0", str(tmp_path / "new_app")],
308+
input="3\n",
309+
)
310+
assert result.exit_code == 0
311+
312+
event = json.loads(temp_queue.read_text().splitlines()[0])
313+
source = json.loads(event["source_extension_json"])
314+
assert source["command_run"]["express_mode_action"] == "EXISTING_EXPRESS"
315+
316+
266317
def test_cli_telemetry_skips_when_already_recorded(
267318
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
268319
) -> None:

0 commit comments

Comments
 (0)