Skip to content

Commit 99bbd83

Browse files
lkang172wukath
authored andcommitted
feat: add parent terminal grouping and TTL pruning to ADK CLI telemetry
Introduce logic to group sequential ADK CLI execution logs under a single logical tracking session if they are launched within the same terminal shell session and within a 1-hour activity window. Key changes: - Associate session tracking with the parent process ID (PPID) of the launching terminal shell. - Manage sessions locally under a sessions storage file. - Prune active session records idle for more than an hour to prevent size growth. - Add comprehensive suite of unit tests verifying metrics collection, sequence tracking, and pruning behaviors. Co-authored-by: Lucas Kang <lucaskang@google.com> PiperOrigin-RevId: 956189218
1 parent 2b1204d commit 99bbd83

7 files changed

Lines changed: 253 additions & 71 deletions

File tree

‎src/google/adk/cli/_telemetry/_constants.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,3 +22,5 @@
2222
LOCK_FILE = os.path.expanduser("~/.adk/clearcut_lock")
2323
# Local JSONL file where command metric logs are queued before flushing.
2424
QUEUE_FILE = os.path.expanduser("~/.adk/telemetry_queue.jsonl")
25+
# Local directory mapping terminal parent PIDs to their active sessions.
26+
TELEMETRY_SESSIONS_DIR = os.path.expanduser("~/.adk/telemetry_sessions")

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

Lines changed: 95 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -31,9 +31,11 @@
3131

3232
import click
3333
from google.adk.cli._telemetry import _constants
34-
from google.adk.utils import _telemetry_config
3534
import google.adk.version
3635

36+
# 1 hour of quiet time marks the end of a logical work session
37+
_SESSION_INACTIVITY_TIMEOUT_SECONDS = 3600
38+
3739
# Constants protecting telemetry storage and preventing client/endpoint abuse.
3840
# Prevents haywire scripts or malicious inputs from flooding log files
3941
# or sending excessively bloated payloads to the public Clearcut server.
@@ -45,20 +47,87 @@
4547
logger = logging.getLogger("google_adk." + __name__)
4648

4749

48-
class MetricsCollector:
49-
"""Singleton for collecting and reporting ADK CLI telemetry."""
50+
def _prune_expired_sessions() -> None:
51+
"""Prunes session files modified more than 1 hour ago."""
52+
if not os.path.exists(_constants.TELEMETRY_SESSIONS_DIR):
53+
return
54+
current_time = time.time()
55+
try:
56+
for filename in os.listdir(_constants.TELEMETRY_SESSIONS_DIR):
57+
file_path = os.path.join(_constants.TELEMETRY_SESSIONS_DIR, filename)
58+
try:
59+
if filename.endswith(".json"):
60+
try:
61+
with open(file_path, "r", encoding="utf-8") as f:
62+
info = json.load(f)
63+
last_activity = info.get("last_activity", 0)
64+
except (OSError, json.JSONDecodeError, KeyError, TypeError):
65+
# If the JSON file is unreadable/corrupted, prune it.
66+
last_activity = 0
67+
if (
68+
current_time - last_activity
69+
) > _SESSION_INACTIVITY_TIMEOUT_SECONDS:
70+
os.remove(file_path)
71+
elif filename.endswith(".tmp"):
72+
os.remove(file_path)
73+
except OSError:
74+
pass
75+
except OSError:
76+
pass
5077

51-
_instance = None
52-
_lock = threading.Lock()
5378

54-
@classmethod
55-
def get_collector(cls) -> Optional["MetricsCollector"]:
56-
if _telemetry_config.read_telemetry_consent() is not True:
57-
return None
58-
with cls._lock:
59-
if not cls._instance:
60-
cls._instance = cls()
61-
return cls._instance
79+
def _load_session_state() -> tuple[str, int]:
80+
"""Retrieves the session state, resetting it if idle for over an hour."""
81+
session_file = os.path.join(
82+
_constants.TELEMETRY_SESSIONS_DIR, f"{os.getppid()}.json"
83+
)
84+
try:
85+
with open(session_file, "r", encoding="utf-8") as f:
86+
info = json.load(f)
87+
if isinstance(info, dict):
88+
last_activity = info.get("last_activity", 0)
89+
if (time.time() - last_activity) < _SESSION_INACTIVITY_TIMEOUT_SECONDS:
90+
session_id = info.get("session_id")
91+
if not session_id:
92+
return str(uuid.uuid4()), 0
93+
return session_id, info.get("sequence_number", 0)
94+
except (OSError, json.JSONDecodeError, KeyError, TypeError):
95+
pass
96+
return str(uuid.uuid4()), 0
97+
98+
99+
def _write_session_state(session_id: str, sequence_number: int) -> None:
100+
"""Saves the current session metadata back to local disk storage."""
101+
_prune_expired_sessions()
102+
session_file = os.path.join(
103+
_constants.TELEMETRY_SESSIONS_DIR, f"{os.getppid()}.json"
104+
)
105+
temp_file = None
106+
try:
107+
info = {
108+
"session_id": session_id,
109+
"sequence_number": sequence_number,
110+
"last_activity": time.time(),
111+
}
112+
113+
temp_file = f"{session_file}.{os.getpid()}.tmp"
114+
os.makedirs(os.path.dirname(session_file), exist_ok=True)
115+
with open(temp_file, "w", encoding="utf-8") as f:
116+
json.dump(info, f)
117+
os.replace(temp_file, session_file)
118+
temp_file = None
119+
except OSError:
120+
pass
121+
finally:
122+
if temp_file is not None:
123+
try:
124+
os.remove(temp_file)
125+
except OSError:
126+
pass
127+
128+
129+
class MetricsCollector:
130+
"""Collector for capturing and queueing ADK CLI telemetry."""
62131

63132
@staticmethod
64133
def _is_rate_limited() -> bool:
@@ -75,10 +144,11 @@ def _is_rate_limited() -> bool:
75144
return True
76145

77146
def __init__(self) -> None:
78-
# Unique UUID per CLI run to group all events in this session.
79-
self._session_id = str(uuid.uuid4())
80-
# Monotonically increasing counter to order events within this session.
81-
self._sequence_number = 0
147+
self._lock = threading.Lock()
148+
# Load session metadata matching parent terminal process
149+
# We generate an ephemeral session ID and sequence number to group commands
150+
# executed in the same terminal session for insightful metrics analytics.
151+
self._session_id, self._sequence_number = _load_session_state()
82152

83153
self._environment = {
84154
"os_type": platform.system().lower(),
@@ -87,14 +157,15 @@ def __init__(self) -> None:
87157
"adk_version": google.adk.version.__version__,
88158
}
89159
logger.debug(
90-
"Initialized ADK metrics collector with session %s",
160+
"Initialized ADK metrics collector with session %s (seq %d)",
91161
self._session_id,
162+
self._sequence_number,
92163
)
93164
atexit.register(self.shutdown)
94165

95166
@staticmethod
96167
def _gather_flags_from_click() -> Optional[List[str]]:
97-
"""Gathers used flags and argument names from Click context."""
168+
"""Gathers used flags and positional argument names from Click context."""
98169
ctx = click.get_current_context(silent=True)
99170
if not ctx:
100171
return None
@@ -125,9 +196,11 @@ def record_command_run(
125196
duration_ms: int = 0,
126197
exception_type: str = "",
127198
) -> None:
128-
"""Records a CLI command execution and appends to local disk queue."""
129-
self._sequence_number += 1
130-
199+
"""Records a command execution and safely appends to local disk queue."""
200+
with self._lock:
201+
self._sequence_number += 1
202+
# Save the updated sequence number back to the master file
203+
_write_session_state(self._session_id, self._sequence_number)
131204
# Enforce string length constraints
132205
command = command[:_MAX_STRING_LENGTH] if command else ""
133206
subcommand = subcommand[:_MAX_STRING_LENGTH] if subcommand else ""

‎src/google/adk/cli/_telemetry/_metrics_reporter.py‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,15 +24,16 @@
2424
import urllib.request
2525
import uuid
2626

27+
try:
28+
from google.adk.cli._telemetry import _constants
29+
except ImportError:
30+
import _constants # type: ignore[no-redef]
31+
2732
# Exponential backoff retry intervals (in seconds) for connection retries.
2833
_RETRY_BACKOFF_WAIT_TIMES = (1, 2)
2934
# Max network connection and read timeout (in seconds) for HTTP requests.
3035
_TIMEOUT_IN_SEC = 5
3136

32-
try:
33-
from google.adk.cli._telemetry import _constants
34-
except ImportError:
35-
import _constants # type: ignore[no-redef]
3637

3738
# Clearcut registration details for Google ADK logs.
3839
_LOG_SOURCE_INT = 3007

‎src/google/adk/cli/cli_tools_click.py‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -303,8 +303,9 @@ def invoke(self, ctx: click.Context) -> Any:
303303
except Exception: # pylint: disable=broad-except
304304
pass
305305

306-
collector = MetricsCollector.get_collector()
307-
if collector:
306+
# Check consent before instantiating MetricsCollector
307+
if read_telemetry_consent() is True:
308+
collector = MetricsCollector()
308309
with sub_ctx if sub_ctx else contextlib.nullcontext():
309310
collector.record_command_run(
310311
command=command,

0 commit comments

Comments
 (0)