blob: d90e9039f26c6db625fb35c9f9cc13f1f3bf5935 [file] [log] [blame]
Ian Kasprzak30bc3542020-12-23 10:08:20 -08001# 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
Josh Steadmon244c9a72022-03-08 10:24:43 -080032import errno
Ian Kasprzak30bc3542020-12-23 10:08:20 -080033import json
34import os
Josh Steadmon244c9a72022-03-08 10:24:43 -080035import socket
Ian Kasprzak30bc3542020-12-23 10:08:20 -080036import sys
37import tempfile
38import threading
39
40from git_command import GitCommand, RepoSourceVersion
41
42
43class EventLog(object):
Gavin Makea2e3302023-03-11 06:46:20 +000044 """Event log that records events that occurred during a repo invocation.
Ian Kasprzak30bc3542020-12-23 10:08:20 -080045
Gavin Makea2e3302023-03-11 06:46:20 +000046 Events are written to the log as a consecutive JSON entries, one per line.
47 Entries follow the git trace2 EVENT format.
Ian Kasprzak30bc3542020-12-23 10:08:20 -080048
Gavin Makea2e3302023-03-11 06:46:20 +000049 Each entry contains the following common keys:
50 - event: The event name
51 - sid: session-id - Unique string to allow process instance to be
52 identified.
53 - thread: The thread name.
54 - time: is the UTC time of the event.
Ian Kasprzak30bc3542020-12-23 10:08:20 -080055
Gavin Makea2e3302023-03-11 06:46:20 +000056 Valid 'event' names and event specific fields are documented here:
57 https://git-scm.com/docs/api-trace2#_event_format
Josh Steadmon244c9a72022-03-08 10:24:43 -080058 """
59
Gavin Makea2e3302023-03-11 06:46:20 +000060 def __init__(self, env=None):
61 """Initializes the event log."""
62 self._log = []
63 # Try to get session-id (sid) from environment (setup in repo launcher).
64 KEY = "GIT_TRACE2_PARENT_SID"
65 if env is None:
66 env = os.environ
Josh Steadmon244c9a72022-03-08 10:24:43 -080067
Gavin Makea2e3302023-03-11 06:46:20 +000068 now = datetime.datetime.utcnow()
Ian Kasprzak30bc3542020-12-23 10:08:20 -080069
Gavin Makea2e3302023-03-11 06:46:20 +000070 # Save both our sid component and the complete sid.
71 # We use our sid component (self._sid) as the unique filename prefix and
72 # the full sid (self._full_sid) in the log itself.
73 self._sid = "repo-%s-P%08x" % (
74 now.strftime("%Y%m%dT%H%M%SZ"),
75 os.getpid(),
76 )
77 parent_sid = env.get(KEY)
78 # Append our sid component to the parent sid (if it exists).
79 if parent_sid is not None:
80 self._full_sid = parent_sid + "/" + self._sid
81 else:
82 self._full_sid = self._sid
Ian Kasprzak30bc3542020-12-23 10:08:20 -080083
Gavin Makea2e3302023-03-11 06:46:20 +000084 # Set/update the environment variable.
85 # Environment handling across systems is messy.
Josh Steadmon244c9a72022-03-08 10:24:43 -080086 try:
Gavin Makea2e3302023-03-11 06:46:20 +000087 env[KEY] = self._full_sid
88 except UnicodeEncodeError:
89 env[KEY] = self._full_sid.encode()
90
91 # Add a version event to front of the log.
92 self._AddVersionEvent()
93
94 @property
95 def full_sid(self):
96 return self._full_sid
97
98 def _AddVersionEvent(self):
99 """Adds a 'version' event at the beginning of current log."""
100 version_event = self._CreateEventDict("version")
101 version_event["evt"] = "2"
102 version_event["exe"] = RepoSourceVersion()
103 self._log.insert(0, version_event)
104
105 def _CreateEventDict(self, event_name):
106 """Returns a dictionary with common keys/values for git trace2 events.
107
108 Args:
109 event_name: The event name.
110
111 Returns:
112 Dictionary with the common event fields populated.
113 """
114 return {
115 "event": event_name,
116 "sid": self._full_sid,
117 "thread": threading.current_thread().name,
118 "time": datetime.datetime.utcnow().isoformat() + "Z",
119 }
120
121 def StartEvent(self):
122 """Append a 'start' event to the current log."""
123 start_event = self._CreateEventDict("start")
124 start_event["argv"] = sys.argv
125 self._log.append(start_event)
126
127 def ExitEvent(self, result):
128 """Append an 'exit' event to the current log.
129
130 Args:
131 result: Exit code of the event
132 """
133 exit_event = self._CreateEventDict("exit")
134
135 # Consider 'None' success (consistent with event_log result handling).
136 if result is None:
137 result = 0
138 exit_event["code"] = result
139 self._log.append(exit_event)
140
141 def CommandEvent(self, name, subcommands):
142 """Append a 'command' event to the current log.
143
144 Args:
145 name: Name of the primary command (ex: repo, git)
146 subcommands: List of the sub-commands (ex: version, init, sync)
147 """
148 command_event = self._CreateEventDict("command")
149 command_event["name"] = name
150 command_event["subcommands"] = subcommands
151 self._log.append(command_event)
152
153 def LogConfigEvents(self, config, event_dict_name):
154 """Append a |event_dict_name| event for each config key in |config|.
155
156 Args:
157 config: Configuration dictionary.
158 event_dict_name: Name of the event dictionary for items to be logged
159 under.
160 """
161 for param, value in config.items():
162 event = self._CreateEventDict(event_dict_name)
163 event["param"] = param
164 event["value"] = value
165 self._log.append(event)
166
167 def DefParamRepoEvents(self, config):
168 """Append 'def_param' events for repo config keys to the current log.
169
170 This appends one event for each repo.* config key.
171
172 Args:
173 config: Repo configuration dictionary
174 """
175 # Only output the repo.* config parameters.
176 repo_config = {k: v for k, v in config.items() if k.startswith("repo.")}
177 self.LogConfigEvents(repo_config, "def_param")
178
179 def GetDataEventName(self, value):
180 """Returns 'data-json' if the value is an array else returns 'data'."""
181 return "data-json" if value[0] == "[" and value[-1] == "]" else "data"
182
183 def LogDataConfigEvents(self, config, prefix):
184 """Append a 'data' event for each entry in |config| to the current log.
185
186 For each keyX and valueX of the config, "key" field of the event is
187 '|prefix|/keyX' and the "value" of the "key" field is valueX.
188
189 Args:
190 config: Configuration dictionary.
191 prefix: Prefix for each key that is logged.
192 """
193 for key, value in config.items():
194 event = self._CreateEventDict(self.GetDataEventName(value))
195 event["key"] = f"{prefix}/{key}"
196 event["value"] = value
197 self._log.append(event)
198
199 def ErrorEvent(self, msg, fmt):
200 """Append a 'error' event to the current log."""
201 error_event = self._CreateEventDict("error")
202 error_event["msg"] = msg
203 error_event["fmt"] = fmt
204 self._log.append(error_event)
205
206 def _GetEventTargetPath(self):
207 """Get the 'trace2.eventtarget' path from git configuration.
208
209 Returns:
210 path: git config's 'trace2.eventtarget' path if it exists, or None
211 """
212 path = None
213 cmd = ["config", "--get", "trace2.eventtarget"]
214 # TODO(https://crbug.com/gerrit/13706): Use GitConfig when it supports
215 # system git config variables.
216 p = GitCommand(
217 None, cmd, capture_stdout=True, capture_stderr=True, bare=True
218 )
219 retval = p.Wait()
220 if retval == 0:
221 # Strip trailing carriage-return in path.
222 path = p.stdout.rstrip("\n")
223 elif retval != 1:
224 # `git config --get` is documented to produce an exit status of `1`
225 # if the requested variable is not present in the configuration.
226 # Report any other return value as an error.
227 print(
228 "repo: error: 'git config --get' call failed with return code: "
229 "%r, stderr: %r" % (retval, p.stderr),
230 file=sys.stderr,
231 )
232 return path
233
234 def _WriteLog(self, write_fn):
235 """Writes the log out using a provided writer function.
236
237 Generate compact JSON output for each item in the log, and write it
238 using write_fn.
239
240 Args:
241 write_fn: A function that accepts byts and writes them to a
242 destination.
243 """
244
245 for e in self._log:
246 # Dump in compact encoding mode.
247 # See 'Compact encoding' in Python docs:
248 # https://docs.python.org/3/library/json.html#module-json
249 write_fn(
250 json.dumps(e, indent=None, separators=(",", ":")).encode(
251 "utf-8"
252 )
253 + b"\n"
254 )
255
256 def Write(self, path=None):
257 """Writes the log out to a file or socket.
258
259 Log is only written if 'path' or 'git config --get trace2.eventtarget'
260 provide a valid path (or socket) to write logs to.
261
262 Logging filename format follows the git trace2 style of being a unique
263 (exclusive writable) file.
264
265 Args:
266 path: Path to where logs should be written. The path may have a
267 prefix of the form "af_unix:[{stream|dgram}:]", in which case
268 the path is treated as a Unix domain socket. See
269 https://git-scm.com/docs/api-trace2#_enabling_a_target for
270 details.
271
272 Returns:
273 log_path: Path to the log file or socket if log is written,
274 otherwise None
275 """
276 log_path = None
277 # If no logging path is specified, get the path from
278 # 'trace2.eventtarget'.
279 if path is None:
280 path = self._GetEventTargetPath()
281
282 # If no logging path is specified, exit.
283 if path is None:
Josh Steadmon244c9a72022-03-08 10:24:43 -0800284 return None
Josh Steadmon244c9a72022-03-08 10:24:43 -0800285
Gavin Makea2e3302023-03-11 06:46:20 +0000286 path_is_socket = False
287 socket_type = None
288 if isinstance(path, str):
289 parts = path.split(":", 1)
290 if parts[0] == "af_unix" and len(parts) == 2:
291 path_is_socket = True
292 path = parts[1]
293 parts = path.split(":", 1)
294 if parts[0] == "stream" and len(parts) == 2:
295 socket_type = socket.SOCK_STREAM
296 path = parts[1]
297 elif parts[0] == "dgram" and len(parts) == 2:
298 socket_type = socket.SOCK_DGRAM
299 path = parts[1]
300 else:
301 # Get absolute path.
302 path = os.path.abspath(os.path.expanduser(path))
303 else:
304 raise TypeError("path: str required but got %s." % type(path))
305
306 # Git trace2 requires a directory to write log to.
307
308 # TODO(https://crbug.com/gerrit/13706): Support file (append) mode also.
309 if not (path_is_socket or os.path.isdir(path)):
310 return None
311
312 if path_is_socket:
313 if socket_type == socket.SOCK_STREAM or socket_type is None:
314 try:
315 with socket.socket(
316 socket.AF_UNIX, socket.SOCK_STREAM
317 ) as sock:
318 sock.connect(path)
319 self._WriteLog(sock.sendall)
320 return f"af_unix:stream:{path}"
321 except OSError as err:
322 # If we tried to connect to a DGRAM socket using STREAM,
323 # ignore the attempt and continue to DGRAM below. Otherwise,
324 # issue a warning.
325 if err.errno != errno.EPROTOTYPE:
326 print(
327 f"repo: warning: git trace2 logging failed: {err}",
328 file=sys.stderr,
329 )
330 return None
331 if socket_type == socket.SOCK_DGRAM or socket_type is None:
332 try:
333 with socket.socket(
334 socket.AF_UNIX, socket.SOCK_DGRAM
335 ) as sock:
336 self._WriteLog(lambda bs: sock.sendto(bs, path))
337 return f"af_unix:dgram:{path}"
338 except OSError as err:
339 print(
340 f"repo: warning: git trace2 logging failed: {err}",
341 file=sys.stderr,
342 )
343 return None
344 # Tried to open a socket but couldn't connect (SOCK_STREAM) or write
345 # (SOCK_DGRAM).
346 print(
347 "repo: warning: git trace2 logging failed: could not write to "
348 "socket",
349 file=sys.stderr,
350 )
351 return None
352
353 # Path is an absolute path
354 # Use NamedTemporaryFile to generate a unique filename as required by
355 # git trace2.
356 try:
357 with tempfile.NamedTemporaryFile(
358 mode="xb", prefix=self._sid, dir=path, delete=False
359 ) as f:
360 # TODO(https://crbug.com/gerrit/13706): Support writing events
361 # as they occur.
362 self._WriteLog(f.write)
363 log_path = f.name
364 except FileExistsError as err:
365 print(
366 "repo: warning: git trace2 logging failed: %r" % err,
367 file=sys.stderr,
368 )
369 return None
370 return log_path