3131
3232import click
3333from google .adk .cli ._telemetry import _constants
34- from google .adk .utils import _telemetry_config
3534import 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.
4547logger = 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 ""
0 commit comments