blob: 56db7a8f5ba1ef42dec3b479ca976b253e39e807 [file] [log] [blame]
Jason Changf19b3102023-09-01 16:07:34 -07001# Copyright (C) 2020 The Android Open Source Project
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"""Provide event logging in the git trace2 EVENT format.
16
17The git trace2 EVENT format is defined at:
18https://www.kernel.org/pub/software/scm/git/docs/technical/api-trace2.html#_event_format
19https://git-scm.com/docs/api-trace2#_the_event_format_target
20
21 Usage:
22
23 git_trace_log = EventLog()
24 git_trace_log.StartEvent()
25 ...
26 git_trace_log.ExitEvent()
27 git_trace_log.Write()
28"""
29
30
31import datetime
32import errno
33import json
34import os
35import socket
36import sys
37import tempfile
38import threading
39
40
Josip Sokcevice5fb6e52023-12-19 09:45:44 -080041# Timeout when sending events via socket (applies to connect, send)
42SOCK_TIMEOUT = 0.5 # in seconds
Jason Changf19b3102023-09-01 16:07:34 -070043# BaseEventLog __init__ Counter that is consistent within the same process
44p_init_count = 0
45
46
Mike Frysingerd4aee652023-10-19 05:13:32 -040047class BaseEventLog:
Jason Changf19b3102023-09-01 16:07:34 -070048 """Event log that records events that occurred during a repo invocation.
49
50 Events are written to the log as a consecutive JSON entries, one per line.
51 Entries follow the git trace2 EVENT format.
52
53 Each entry contains the following common keys:
54 - event: The event name
55 - sid: session-id - Unique string to allow process instance to be
56 identified.
57 - thread: The thread name.
58 - time: is the UTC time of the event.
59
60 Valid 'event' names and event specific fields are documented here:
61 https://git-scm.com/docs/api-trace2#_event_format
62 """
63
64 def __init__(
65 self, env=None, repo_source_version=None, add_init_count=False
66 ):
67 """Initializes the event log."""
68 global p_init_count
69 p_init_count += 1
70 self._log = []
71 # Try to get session-id (sid) from environment (setup in repo launcher).
72 KEY = "GIT_TRACE2_PARENT_SID"
73 if env is None:
74 env = os.environ
75
LuK1337aadd12c2023-09-16 09:36:49 +020076 self.start = datetime.datetime.now(datetime.timezone.utc)
Jason Changf19b3102023-09-01 16:07:34 -070077
78 # Save both our sid component and the complete sid.
79 # We use our sid component (self._sid) as the unique filename prefix and
80 # the full sid (self._full_sid) in the log itself.
Jason R. Coombsb32ccbb2023-09-29 11:04:49 -040081 self._sid = (
82 f"repo-{self.start.strftime('%Y%m%dT%H%M%SZ')}-P{os.getpid():08x}"
Jason Changf19b3102023-09-01 16:07:34 -070083 )
84
85 if add_init_count:
86 self._sid = f"{self._sid}-{p_init_count}"
87
88 parent_sid = env.get(KEY)
89 # Append our sid component to the parent sid (if it exists).
90 if parent_sid is not None:
91 self._full_sid = parent_sid + "/" + self._sid
92 else:
93 self._full_sid = self._sid
94
95 # Set/update the environment variable.
96 # Environment handling across systems is messy.
97 try:
98 env[KEY] = self._full_sid
99 except UnicodeEncodeError:
100 env[KEY] = self._full_sid.encode()
101
102 if repo_source_version is not None:
103 # Add a version event to front of the log.
104 self._AddVersionEvent(repo_source_version)
105
106 @property
107 def full_sid(self):
108 return self._full_sid
109
110 def _AddVersionEvent(self, repo_source_version):
111 """Adds a 'version' event at the beginning of current log."""
112 version_event = self._CreateEventDict("version")
113 version_event["evt"] = "2"
114 version_event["exe"] = repo_source_version
115 self._log.insert(0, version_event)
116
117 def _CreateEventDict(self, event_name):
118 """Returns a dictionary with common keys/values for git trace2 events.
119
120 Args:
121 event_name: The event name.
122
123 Returns:
124 Dictionary with the common event fields populated.
125 """
126 return {
127 "event": event_name,
128 "sid": self._full_sid,
129 "thread": threading.current_thread().name,
LuK1337aadd12c2023-09-16 09:36:49 +0200130 "time": datetime.datetime.now(datetime.timezone.utc).isoformat(),
Jason Changf19b3102023-09-01 16:07:34 -0700131 }
132
Josip Sokcevicfafd1ec2024-11-22 00:02:40 +0000133 def StartEvent(self, argv):
Jason Changf19b3102023-09-01 16:07:34 -0700134 """Append a 'start' event to the current log."""
135 start_event = self._CreateEventDict("start")
Josip Sokcevicfafd1ec2024-11-22 00:02:40 +0000136 start_event["argv"] = argv
Jason Changf19b3102023-09-01 16:07:34 -0700137 self._log.append(start_event)
138
139 def ExitEvent(self, result):
140 """Append an 'exit' event to the current log.
141
142 Args:
143 result: Exit code of the event
144 """
145 exit_event = self._CreateEventDict("exit")
146
147 # Consider 'None' success (consistent with event_log result handling).
148 if result is None:
149 result = 0
150 exit_event["code"] = result
LuK1337aadd12c2023-09-16 09:36:49 +0200151 time_delta = datetime.datetime.now(datetime.timezone.utc) - self.start
Jason Changf19b3102023-09-01 16:07:34 -0700152 exit_event["t_abs"] = time_delta.total_seconds()
153 self._log.append(exit_event)
154
155 def CommandEvent(self, name, subcommands):
156 """Append a 'command' event to the current log.
157
158 Args:
159 name: Name of the primary command (ex: repo, git)
160 subcommands: List of the sub-commands (ex: version, init, sync)
161 """
Josip Sokcevicfafd1ec2024-11-22 00:02:40 +0000162 command_event = self._CreateEventDict("cmd_name")
163 name = f"{name}-"
164 name += "-".join(subcommands)
Jason Changf19b3102023-09-01 16:07:34 -0700165 command_event["name"] = name
Josip Sokcevicfafd1ec2024-11-22 00:02:40 +0000166 command_event["hierarchy"] = name
Jason Changf19b3102023-09-01 16:07:34 -0700167 self._log.append(command_event)
168
169 def LogConfigEvents(self, config, event_dict_name):
170 """Append a |event_dict_name| event for each config key in |config|.
171
172 Args:
173 config: Configuration dictionary.
174 event_dict_name: Name of the event dictionary for items to be logged
175 under.
176 """
177 for param, value in config.items():
178 event = self._CreateEventDict(event_dict_name)
179 event["param"] = param
180 event["value"] = value
181 self._log.append(event)
182
183 def DefParamRepoEvents(self, config):
184 """Append 'def_param' events for repo config keys to the current log.
185
186 This appends one event for each repo.* config key.
187
188 Args:
189 config: Repo configuration dictionary
190 """
191 # Only output the repo.* config parameters.
192 repo_config = {k: v for k, v in config.items() if k.startswith("repo.")}
193 self.LogConfigEvents(repo_config, "def_param")
194
195 def GetDataEventName(self, value):
196 """Returns 'data-json' if the value is an array else returns 'data'."""
197 return "data-json" if value[0] == "[" and value[-1] == "]" else "data"
198
199 def LogDataConfigEvents(self, config, prefix):
200 """Append a 'data' event for each entry in |config| to the current log.
201
202 For each keyX and valueX of the config, "key" field of the event is
203 '|prefix|/keyX' and the "value" of the "key" field is valueX.
204
205 Args:
206 config: Configuration dictionary.
207 prefix: Prefix for each key that is logged.
208 """
209 for key, value in config.items():
210 event = self._CreateEventDict(self.GetDataEventName(value))
211 event["key"] = f"{prefix}/{key}"
212 event["value"] = value
213 self._log.append(event)
214
215 def ErrorEvent(self, msg, fmt=None):
216 """Append a 'error' event to the current log."""
217 error_event = self._CreateEventDict("error")
218 if fmt is None:
219 fmt = msg
220 error_event["msg"] = f"RepoErrorEvent:{msg}"
221 error_event["fmt"] = f"RepoErrorEvent:{fmt}"
222 self._log.append(error_event)
223
224 def _WriteLog(self, write_fn):
225 """Writes the log out using a provided writer function.
226
227 Generate compact JSON output for each item in the log, and write it
228 using write_fn.
229
230 Args:
231 write_fn: A function that accepts byts and writes them to a
232 destination.
233 """
234
235 for e in self._log:
236 # Dump in compact encoding mode.
237 # See 'Compact encoding' in Python docs:
238 # https://docs.python.org/3/library/json.html#module-json
239 write_fn(
240 json.dumps(e, indent=None, separators=(",", ":")).encode(
241 "utf-8"
242 )
243 + b"\n"
244 )
245
246 def Write(self, path=None):
247 """Writes the log out to a file or socket.
248
249 Log is only written if 'path' or 'git config --get trace2.eventtarget'
250 provide a valid path (or socket) to write logs to.
251
252 Logging filename format follows the git trace2 style of being a unique
253 (exclusive writable) file.
254
255 Args:
256 path: Path to where logs should be written. The path may have a
257 prefix of the form "af_unix:[{stream|dgram}:]", in which case
258 the path is treated as a Unix domain socket. See
259 https://git-scm.com/docs/api-trace2#_enabling_a_target for
260 details.
261
262 Returns:
263 log_path: Path to the log file or socket if log is written,
264 otherwise None
265 """
266 log_path = None
267 # If no logging path is specified, exit.
268 if path is None:
269 return None
270
271 path_is_socket = False
272 socket_type = None
273 if isinstance(path, str):
274 parts = path.split(":", 1)
275 if parts[0] == "af_unix" and len(parts) == 2:
276 path_is_socket = True
277 path = parts[1]
278 parts = path.split(":", 1)
279 if parts[0] == "stream" and len(parts) == 2:
280 socket_type = socket.SOCK_STREAM
281 path = parts[1]
282 elif parts[0] == "dgram" and len(parts) == 2:
283 socket_type = socket.SOCK_DGRAM
284 path = parts[1]
285 else:
286 # Get absolute path.
287 path = os.path.abspath(os.path.expanduser(path))
288 else:
289 raise TypeError("path: str required but got %s." % type(path))
290
291 # Git trace2 requires a directory to write log to.
292
293 # TODO(https://crbug.com/gerrit/13706): Support file (append) mode also.
294 if not (path_is_socket or os.path.isdir(path)):
295 return None
296
297 if path_is_socket:
298 if socket_type == socket.SOCK_STREAM or socket_type is None:
299 try:
300 with socket.socket(
301 socket.AF_UNIX, socket.SOCK_STREAM
302 ) as sock:
Josip Sokcevice5fb6e52023-12-19 09:45:44 -0800303 sock.settimeout(SOCK_TIMEOUT)
Jason Changf19b3102023-09-01 16:07:34 -0700304 sock.connect(path)
305 self._WriteLog(sock.sendall)
306 return f"af_unix:stream:{path}"
307 except OSError as err:
308 # If we tried to connect to a DGRAM socket using STREAM,
309 # ignore the attempt and continue to DGRAM below. Otherwise,
310 # issue a warning.
311 if err.errno != errno.EPROTOTYPE:
312 print(
313 f"repo: warning: git trace2 logging failed: {err}",
314 file=sys.stderr,
315 )
316 return None
317 if socket_type == socket.SOCK_DGRAM or socket_type is None:
318 try:
319 with socket.socket(
320 socket.AF_UNIX, socket.SOCK_DGRAM
321 ) as sock:
322 self._WriteLog(lambda bs: sock.sendto(bs, path))
323 return f"af_unix:dgram:{path}"
324 except OSError as err:
325 print(
326 f"repo: warning: git trace2 logging failed: {err}",
327 file=sys.stderr,
328 )
329 return None
330 # Tried to open a socket but couldn't connect (SOCK_STREAM) or write
331 # (SOCK_DGRAM).
332 print(
333 "repo: warning: git trace2 logging failed: could not write to "
334 "socket",
335 file=sys.stderr,
336 )
337 return None
338
339 # Path is an absolute path
340 # Use NamedTemporaryFile to generate a unique filename as required by
341 # git trace2.
342 try:
343 with tempfile.NamedTemporaryFile(
344 mode="xb", prefix=self._sid, dir=path, delete=False
345 ) as f:
346 # TODO(https://crbug.com/gerrit/13706): Support writing events
347 # as they occur.
348 self._WriteLog(f.write)
349 log_path = f.name
350 except FileExistsError as err:
351 print(
352 "repo: warning: git trace2 logging failed: %r" % err,
353 file=sys.stderr,
354 )
355 return None
356 return log_path