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.android.tradefed.clearcut;
17 
18 import com.android.annotations.VisibleForTesting;
19 import com.android.asuite.clearcut.Clientanalytics.ClientInfo;
20 import com.android.asuite.clearcut.Clientanalytics.LogEvent;
21 import com.android.asuite.clearcut.Clientanalytics.LogRequest;
22 import com.android.asuite.clearcut.Clientanalytics.LogResponse;
23 import com.android.asuite.clearcut.Common.UserType;
24 import com.android.tradefed.log.LogUtil.CLog;
25 import com.android.tradefed.util.CommandResult;
26 import com.android.tradefed.util.CommandStatus;
27 import com.android.tradefed.util.FileUtil;
28 import com.android.tradefed.util.RunUtil;
29 import com.android.tradefed.util.StreamUtil;
30 import com.android.tradefed.util.net.HttpHelper;
31 
32 import com.google.protobuf.util.JsonFormat;
33 
34 import java.io.File;
35 import java.io.IOException;
36 import java.io.InputStream;
37 import java.io.OutputStream;
38 import java.io.OutputStreamWriter;
39 import java.net.HttpURLConnection;
40 import java.net.InetAddress;
41 import java.net.URL;
42 import java.net.UnknownHostException;
43 import java.util.ArrayList;
44 import java.util.List;
45 import java.util.UUID;
46 import java.util.concurrent.ScheduledThreadPoolExecutor;
47 import java.util.concurrent.TimeUnit;
48 
49 /** Client that allows reporting usage metrics to clearcut. */
50 public class ClearcutClient {
51 
52     public static final String DISABLE_CLEARCUT_KEY = "DISABLE_CLEARCUT";
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 
59     private static final long SCHEDULER_INITIAL_DELAY_SECONDS = 2;
60     private static final long SCHEDULER_PERDIOC_SECONDS = 30;
61 
62     private static final String GOOGLE_EMAIL = "@google.com";
63     private static final String GOOGLE_HOSTNAME = ".google.com";
64 
65     private File mCachedUuidFile = new File(System.getProperty("user.home"), ".tradefed");
66     private String mRunId;
67 
68     private final int mLogSource;
69     private final String mUrl;
70     private final UserType mUserType;
71 
72     // Consider synchronized list
73     private List<LogRequest> mExternalEventQueue;
74     // The pool executor to actually post the metrics
75     private ScheduledThreadPoolExecutor mExecutor;
76     // Whether the clearcut client should be inop
77     private boolean mDisabled = false;
78 
ClearcutClient()79     public ClearcutClient() {
80         this(null);
81     }
82 
83     /**
84      * Create Client with customized posting URL and forcing whether it's internal or external user.
85      */
86     @VisibleForTesting
ClearcutClient(String url)87     protected ClearcutClient(String url) {
88         mDisabled = isClearcutDisabled();
89 
90         // We still have to set the 'final' variable so go through the assignments before returning
91         if (!mDisabled && isGoogleUser()) {
92             mLogSource = INTERNAL_LOG_SOURCE;
93             mUserType = UserType.GOOGLE;
94         } else {
95             mLogSource = EXTERNAL_LOG_SOURCE;
96             mUserType = UserType.EXTERNAL;
97         }
98         if (url == null) {
99             mUrl = CLEARCUT_PROD_URL;
100         } else {
101             mUrl = url;
102         }
103         mRunId = UUID.randomUUID().toString();
104         mExternalEventQueue = new ArrayList<>();
105 
106         if (mDisabled) {
107             return;
108         }
109 
110         // Print the notice
111         System.out.println(NoticeMessageUtil.getNoticeMessage(mUserType));
112 
113         // Executor to actually send the events.
114         mExecutor = new ScheduledThreadPoolExecutor(1);
115         Runnable command =
116                 new Runnable() {
117                     @Override
118                     public void run() {
119                         flushEvents();
120                     }
121                 };
122         mExecutor.scheduleAtFixedRate(
123                 command,
124                 SCHEDULER_INITIAL_DELAY_SECONDS,
125                 SCHEDULER_PERDIOC_SECONDS,
126                 TimeUnit.SECONDS);
127     }
128 
129     /** Send the first event to notify that Tradefed was started. */
notifyTradefedStartEvent()130     public void notifyTradefedStartEvent() {
131         if (mDisabled) {
132             return;
133         }
134         LogRequest.Builder request = createBaseLogRequest();
135         LogEvent.Builder logEvent = LogEvent.newBuilder();
136         logEvent.setEventTimeMs(System.currentTimeMillis());
137         logEvent.setSourceExtension(
138                 ClearcutEventHelper.createStartEvent(getGroupingKey(), mRunId, mUserType));
139         request.addLogEvent(logEvent);
140         queueEvent(request.build());
141     }
142 
143     /** Send the event to notify that a Tradefed invocation was started. */
notifyTradefedInvocationStartEvent()144     public void notifyTradefedInvocationStartEvent() {
145         if (mDisabled) {
146             return;
147         }
148         LogRequest.Builder request = createBaseLogRequest();
149         LogEvent.Builder logEvent = LogEvent.newBuilder();
150         logEvent.setEventTimeMs(System.currentTimeMillis());
151         logEvent.setSourceExtension(
152                 ClearcutEventHelper.createRunStartEvent(getGroupingKey(), mRunId, mUserType));
153         request.addLogEvent(logEvent);
154         queueEvent(request.build());
155     }
156 
157     /** Stop the periodic sending of clearcut events */
stop()158     public void stop() {
159         if (mExecutor != null) {
160             mExecutor.setRemoveOnCancelPolicy(true);
161             mExecutor.shutdown();
162             mExecutor = null;
163         }
164         // Send all remaining events
165         flushEvents();
166     }
167 
168     /** Add an event to the queue of events that needs to be send. */
queueEvent(LogRequest event)169     public void queueEvent(LogRequest event) {
170         synchronized (mExternalEventQueue) {
171             mExternalEventQueue.add(event);
172         }
173     }
174 
175     /** Returns the current queue size. */
getQueueSize()176     public final int getQueueSize() {
177         synchronized (mExternalEventQueue) {
178             return mExternalEventQueue.size();
179         }
180     }
181 
182     /** Allows to override the default cached uuid file. */
setCachedUuidFile(File uuidFile)183     public void setCachedUuidFile(File uuidFile) {
184         mCachedUuidFile = uuidFile;
185     }
186 
187     /** Get a new or the cached uuid for the user. */
188     @VisibleForTesting
getGroupingKey()189     String getGroupingKey() {
190         String uuid = null;
191         if (mCachedUuidFile.exists()) {
192             try {
193                 uuid = FileUtil.readStringFromFile(mCachedUuidFile);
194             } catch (IOException e) {
195                 CLog.e(e);
196             }
197         }
198         if (uuid == null || uuid.isEmpty()) {
199             uuid = UUID.randomUUID().toString();
200             try {
201                 FileUtil.writeToFile(uuid, mCachedUuidFile);
202             } catch (IOException e) {
203                 CLog.e(e);
204             }
205         }
206         return uuid;
207     }
208 
209     /** Returns True if clearcut is disabled, False otherwise. */
210     @VisibleForTesting
isClearcutDisabled()211     boolean isClearcutDisabled() {
212         return "1".equals(System.getenv(DISABLE_CLEARCUT_KEY));
213     }
214 
215     /** Returns True if the user is a Googler, False otherwise. */
216     @VisibleForTesting
isGoogleUser()217     boolean isGoogleUser() {
218         CommandResult gitRes =
219                 RunUtil.getDefault()
220                         .runTimedCmdSilently(60000L, "git", "config", "--get", "user.email");
221         if (CommandStatus.SUCCESS.equals(gitRes.getStatus())) {
222             String stdout = gitRes.getStdout();
223             if (stdout != null && stdout.trim().endsWith(GOOGLE_EMAIL)) {
224                 return true;
225             }
226         }
227         try {
228             String hostname = InetAddress.getLocalHost().getHostName();
229             if (hostname.contains(GOOGLE_HOSTNAME)) {
230                 return true;
231             }
232         } catch (UnknownHostException e) {
233             // Ignore
234         }
235         return false;
236     }
237 
createBaseLogRequest()238     private LogRequest.Builder createBaseLogRequest() {
239         LogRequest.Builder request = LogRequest.newBuilder();
240         request.setLogSource(mLogSource);
241         request.setClientInfo(ClientInfo.newBuilder().setClientType(CLIENT_TYPE));
242         return request;
243     }
244 
flushEvents()245     private void flushEvents() {
246         List<LogRequest> copy = new ArrayList<>();
247         synchronized (mExternalEventQueue) {
248             copy.addAll(mExternalEventQueue);
249             mExternalEventQueue.clear();
250         }
251         while (!copy.isEmpty()) {
252             LogRequest event = copy.remove(0);
253             sendToClearcut(event);
254         }
255     }
256 
257     /** Send one event to the configured server. */
sendToClearcut(LogRequest event)258     private void sendToClearcut(LogRequest event) {
259         HttpHelper helper = new HttpHelper();
260 
261         InputStream inputStream = null;
262         InputStream errorStream = null;
263         OutputStream outputStream = null;
264         OutputStreamWriter outputStreamWriter = null;
265         try {
266             HttpURLConnection connection = helper.createConnection(new URL(mUrl), "POST", "text");
267             outputStream = connection.getOutputStream();
268             outputStreamWriter = new OutputStreamWriter(outputStream);
269 
270             String jsonObject = JsonFormat.printer().preservingProtoFieldNames().print(event);
271             outputStreamWriter.write(jsonObject.toString());
272             outputStreamWriter.flush();
273 
274             inputStream = connection.getInputStream();
275             LogResponse response = LogResponse.parseFrom(inputStream);
276 
277             errorStream = connection.getErrorStream();
278             if (errorStream != null) {
279                 String message = StreamUtil.getStringFromStream(errorStream);
280                 CLog.e("Error posting clearcut event: '%s'. LogResponse: '%s'", message, response);
281             }
282         } catch (IOException e) {
283             CLog.e(e);
284         } finally {
285             StreamUtil.close(outputStream);
286             StreamUtil.close(inputStream);
287             StreamUtil.close(outputStreamWriter);
288             StreamUtil.close(errorStream);
289         }
290     }
291 }
292