1 /* 2 * Copyright (C) 2019 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 package com.google.asuite.clearcut.junit.listener; 17 18 import com.android.asuite.clearcut.Clientanalytics.ClientInfo; 19 import com.android.asuite.clearcut.Clientanalytics.LogEvent; 20 import com.android.asuite.clearcut.Clientanalytics.LogRequest; 21 import com.android.asuite.clearcut.Clientanalytics.LogResponse; 22 import com.android.asuite.clearcut.Common.UserType; 23 24 import java.io.BufferedReader; 25 import java.io.ByteArrayOutputStream; 26 import java.io.Closeable; 27 import java.io.File; 28 import java.io.IOException; 29 import java.io.InputStream; 30 import java.io.InputStreamReader; 31 import java.io.OutputStream; 32 import java.net.HttpURLConnection; 33 import java.net.URI; 34 import java.net.URISyntaxException; 35 import java.net.URL; 36 import java.nio.charset.StandardCharsets; 37 import java.nio.file.Files; 38 import java.nio.file.StandardOpenOption; 39 import java.time.Duration; 40 import java.util.Objects; 41 import java.util.Optional; 42 import java.util.UUID; 43 import java.util.concurrent.CompletableFuture; 44 import java.util.concurrent.ExecutionException; 45 import java.util.stream.Collectors; 46 import java.util.zip.GZIPOutputStream; 47 48 /** Client that allows reporting usage metrics to clearcut. */ 49 public class Client { 50 51 public static final String DISABLE_CLEARCUT_KEY = "DISABLE_CLEARCUT"; 52 private static final String CLEARCUT_SUB_TOOL_NAME = "CLEARCUT_SUB_TOOL_NAME"; 53 54 private static final String CLEARCUT_PROD_URL = "https://play.googleapis.com/log"; 55 private static final int CLIENT_TYPE = 1; 56 private static final int INTERNAL_LOG_SOURCE = 971; 57 private static final int EXTERNAL_LOG_SOURCE = 934; 58 private File mCachedUuidFile = new File(System.getProperty("user.home"), ".clearcut_listener"); 59 private String mRunId; 60 private long mSessionStartTime = 0L; 61 private final int mLogSource; 62 private final String mUrl; 63 private final UserType mUserType; 64 private final String mToolName; 65 private final String mSubToolName; 66 private final String mUser; 67 private final boolean mIsGoogle; 68 69 // Whether the clearcut client should be a noop 70 private boolean mDisabled = false; 71 Client(String toolName, String subToolName)72 public Client(String toolName, String subToolName) { 73 this(null, toolName, subToolName); 74 Runtime.getRuntime().addShutdownHook(new Thread(Client.this::stop)); 75 } 76 77 /** 78 * Create Client with customized posting URL and forcing whether it's internal or external user. 79 */ Client(String url, String toolName, String subToolName)80 protected Client(String url, String toolName, String subToolName) { 81 mDisabled = isClearcutDisabled(); 82 Optional<String> email = EnvironmentInformation.getGitEmail(); 83 Optional<String> googleUser = EnvironmentInformation.getGitUserIfGoogleEmail(email); 84 mIsGoogle = EnvironmentInformation.isGoogleDomain() || 85 googleUser.isPresent(); 86 Optional<String> username = EnvironmentInformation.executeCommand("whoami"); 87 88 // We still have to set the 'final' variable so go through the assignments before returning 89 if (!mDisabled && isGoogleUser()) { 90 mLogSource = INTERNAL_LOG_SOURCE; 91 mUserType = UserType.GOOGLE; 92 mUser = googleUser.orElse(username.orElse("")); 93 } else { 94 mLogSource = EXTERNAL_LOG_SOURCE; 95 mUserType = UserType.EXTERNAL; 96 mUser = UUID5.uuidOf(UUID5.NAMESPACE_DNS, email.orElse(username.orElse(""))).toString(); 97 } 98 mUrl = Objects.requireNonNullElse(url, CLEARCUT_PROD_URL); 99 mToolName = toolName; 100 mRunId = UUID.randomUUID().toString(); 101 if (subToolName != null && subToolName.isEmpty() && System.getenv(CLEARCUT_SUB_TOOL_NAME) != null) { 102 mSubToolName = System.getenv(CLEARCUT_SUB_TOOL_NAME); 103 } else { 104 mSubToolName = subToolName; 105 } 106 107 if (mDisabled) { 108 return; 109 } 110 111 // Print the notice 112 System.out.println(NoticeMessageUtil.getNoticeMessage(mUserType)); 113 } 114 disable()115 protected void disable(){ 116 mDisabled = true; 117 } 118 isGoogleUser()119 boolean isGoogleUser() { 120 return mIsGoogle; 121 } 122 123 /** Send the first event to notify that Tradefed was started. */ notifyTradefedStartEvent()124 public void notifyTradefedStartEvent() { 125 if (mDisabled) { 126 return; 127 } 128 mSessionStartTime = System.nanoTime(); 129 long eventTimeMs = System.currentTimeMillis(); 130 CompletableFuture.supplyAsync(() -> createAndSendStartEvent(eventTimeMs)); 131 } 132 createAndSendStartEvent(long eventTimeMs)133 private boolean createAndSendStartEvent(long eventTimeMs) { 134 LogEvent.Builder logEvent = LogEvent.newBuilder(); 135 logEvent.setEventTimeMs(eventTimeMs); 136 logEvent.setSourceExtension( 137 ClearcutEventHelper.createStartEvent( 138 mUser, mRunId, mUserType, mToolName, mSubToolName)); 139 LogRequest.Builder request = createBaseLogRequest(); 140 request.addLogEvent(logEvent); 141 sendEvent(request.build()); 142 return true; 143 } 144 notifyTradefedFinishedEvent()145 public void notifyTradefedFinishedEvent() { 146 if (mDisabled) { 147 return; 148 } 149 CompletableFuture.supplyAsync(() -> createAndSendFinishedEvent()); 150 } 151 152 /** Send the last event to notify that Tradefed is done. */ createAndSendFinishedEvent()153 public boolean createAndSendFinishedEvent() { 154 Duration duration = java.time.Duration.ofNanos(System.nanoTime() - mSessionStartTime); 155 LogEvent.Builder logEvent = LogEvent.newBuilder(); 156 logEvent.setEventTimeMs(System.currentTimeMillis()); 157 logEvent.setSourceExtension( 158 ClearcutEventHelper.createFinishedEvent( 159 mUser, mRunId, mUserType, mToolName, mSubToolName, duration)); 160 LogRequest.Builder request = createBaseLogRequest(); 161 request.addLogEvent(logEvent); 162 sendEvent(request.build()); 163 return true; 164 } 165 166 /** Stop the periodic sending of clearcut events */ stop()167 public void stop() { 168 notifyTradefedFinishedEvent(); 169 } 170 171 /** Returns True if clearcut is disabled, False otherwise. */ isClearcutDisabled()172 public boolean isClearcutDisabled() { 173 return "1".equals(System.getenv(DISABLE_CLEARCUT_KEY)); 174 } 175 createBaseLogRequest()176 private LogRequest.Builder createBaseLogRequest() { 177 LogRequest.Builder request = LogRequest.newBuilder(); 178 request.setLogSource(mLogSource); 179 request.setClientInfo(ClientInfo.newBuilder().setClientType(CLIENT_TYPE)); 180 return request; 181 } 182 sendEvent(LogRequest request)183 private void sendEvent(LogRequest request) { 184 CompletableFuture<Boolean> future = CompletableFuture.supplyAsync(() -> sendToClearcut(request)); 185 try { 186 future.get(); 187 } catch (InterruptedException | ExecutionException e) { 188 logError(e); 189 } 190 } 191 192 /** Send one event to the configured server. */ sendToClearcut(LogRequest event)193 private boolean sendToClearcut(LogRequest event) { 194 InputStream inputStream = null; 195 InputStream errorStream = null; 196 OutputStream outputStream = null; 197 try { 198 HttpURLConnection connection = createConnection(new URI(mUrl).toURL(), "POST", null); 199 connection.setRequestProperty("Content-Encoding", "gzip"); 200 connection.setRequestProperty("Content-Type", "application/x-gzip"); 201 outputStream = connection.getOutputStream(); 202 outputStream.write(gzipCompress(event.toByteArray())); 203 outputStream.flush(); 204 205 inputStream = connection.getInputStream(); 206 LogResponse response = LogResponse.parseFrom(inputStream); 207 208 errorStream = connection.getErrorStream(); 209 if (errorStream != null) { 210 String message = readStream(errorStream); 211 System.out.println("Error posting clearcut event: " + message + " LogResponse: " + response); 212 } 213 } catch (IOException | URISyntaxException e) { 214 logError(e); 215 } catch (NoSuchMethodError e) { 216 if (e.getMessage().contains("com.google.protobuf.Descriptors$Descriptor com.google.protobuf.Any.getDescriptor()")) { 217 String message = 218 "In order for the ClearcutListener to operate it must use protobuf-full to be able to convert messages to json.\n" 219 + "Android typically uses protobuf-lite." 220 + "If you're seeing this in a gradle build, adding `testImplementation(project(\":RobolectricLib\"))` to the start of " 221 + "your dependency section should be sufficient, if not (due to how gradle calculates deps), add this dep: " 222 + "`testImplementation(libs.protobuf.java)` to the top of your dependencies"; 223 throw new RuntimeException(message, e); 224 } else { 225 logError(e); 226 throw e; 227 } 228 } catch (Throwable t) { 229 logError(t); 230 throw t; 231 } finally { 232 closeQuietly(outputStream); 233 closeQuietly(inputStream); 234 closeQuietly(errorStream); 235 } 236 return true; 237 } 238 closeQuietly(Closeable c)239 private void closeQuietly(Closeable c) { 240 try { 241 if (c != null) { 242 c.close(); 243 } 244 } catch (IOException ex) { 245 // Intentional No-Op 246 } 247 } 248 logError(Throwable t)249 private void logError(Throwable t) { 250 System.out.println(t); 251 t.printStackTrace(System.out); 252 } 253 readFromFile(File file)254 protected static String readFromFile(File file) throws IOException { 255 return Files.readString(file.toPath()); 256 } 257 writeToFile(String content, File file)258 protected static void writeToFile(String content, File file) throws IOException { 259 Files.writeString(file.toPath(), content, StandardOpenOption.WRITE, StandardOpenOption.CREATE); 260 } 261 readStream(InputStream is)262 protected static String readStream(InputStream is) throws IOException { 263 try (BufferedReader reader = new BufferedReader(new InputStreamReader(is, StandardCharsets.UTF_8))) { 264 return reader.lines().collect(Collectors.joining(System.lineSeparator())); 265 } 266 } 267 createConnection(URL url, String method, String contentType)268 private static HttpURLConnection createConnection(URL url, String method, String contentType) 269 throws IOException { 270 HttpURLConnection connection = (HttpURLConnection) url.openConnection(); 271 connection.setRequestMethod(method); 272 if (contentType != null) { 273 connection.setRequestProperty("Content-Type", contentType); 274 } 275 connection.setDoInput(true); 276 connection.setDoOutput(true); 277 connection.setConnectTimeout(60 * 1000); // timeout for establishing the connection 278 connection.setReadTimeout(60 * 1000); // timeout for receiving a read() response 279 connection.setRequestProperty("User-Agent", 280 String.format("%s/%s", "TradeFederation_like_ClearcutJunitListener", "1.0")); 281 282 return connection; 283 } 284 gzipCompress(byte[] data)285 private static byte[] gzipCompress(byte[] data) throws IOException { 286 ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); 287 try (GZIPOutputStream gzipOutputStream = new GZIPOutputStream(outputStream)) { 288 gzipOutputStream.write(data); 289 } 290 return outputStream.toByteArray(); 291 } 292 } 293