1 /*
2  * Copyright (C) 2016 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 
17 package android.telecom.Logging;
18 
19 import android.annotation.Nullable;
20 import android.content.ContentResolver;
21 import android.content.Context;
22 import android.os.Handler;
23 import android.os.Looper;
24 import android.os.Process;
25 import android.provider.Settings;
26 import android.telecom.Log;
27 import android.util.Base64;
28 
29 import com.android.internal.annotations.VisibleForTesting;
30 
31 import java.nio.ByteBuffer;
32 import java.util.ArrayList;
33 import java.util.Arrays;
34 import java.util.Iterator;
35 import java.util.List;
36 import java.util.concurrent.ConcurrentHashMap;
37 
38 /**
39  * TODO: Create better Sessions Documentation
40  * @hide
41  */
42 
43 public class SessionManager {
44 
45     // Currently using 3 letters, So don't exceed 64^3
46     private static final long SESSION_ID_ROLLOVER_THRESHOLD = 262144;
47     // This parameter can be overridden in Telecom's Timeouts class.
48     private static final long DEFAULT_SESSION_TIMEOUT_MS = 30000L; // 30 seconds
49     private static final String LOGGING_TAG = "Logging";
50     private static final String TIMEOUTS_PREFIX = "telecom.";
51 
52     // Synchronized in all method calls
53     private int sCodeEntryCounter = 0;
54     private Context mContext;
55 
56     @VisibleForTesting
57     public ConcurrentHashMap<Integer, Session> mSessionMapper = new ConcurrentHashMap<>(100);
58     @VisibleForTesting
59     public java.lang.Runnable mCleanStaleSessions = () ->
60             cleanupStaleSessions(getSessionCleanupTimeoutMs());
61     private Handler mSessionCleanupHandler = new Handler(Looper.getMainLooper());
62 
63     // Overridden in LogTest to skip query to ContentProvider
64     private interface ISessionCleanupTimeoutMs {
get()65         long get();
66     }
67 
68     // Overridden in tests to provide test Thread IDs
69     public interface ICurrentThreadId {
get()70         int get();
71     }
72 
73     @VisibleForTesting
74     public ICurrentThreadId mCurrentThreadId = Process::myTid;
75 
76     private ISessionCleanupTimeoutMs mSessionCleanupTimeoutMs = () -> {
77         // mContext may be null in some cases, such as testing. For these cases, use the
78         // default value.
79         if (mContext == null) {
80             return DEFAULT_SESSION_TIMEOUT_MS;
81         }
82         return getCleanupTimeout(mContext);
83     };
84 
85     // Usage is synchronized on this class.
86     private List<ISessionListener> mSessionListeners = new ArrayList<>();
87 
88     public interface ISessionListener {
89         /**
90          * This method is run when a full Session has completed.
91          * @param sessionName The name of the Session that has completed.
92          * @param timeMs The time it took to complete in ms.
93          */
sessionComplete(String sessionName, long timeMs)94         void sessionComplete(String sessionName, long timeMs);
95     }
96 
97     public interface ISessionIdQueryHandler {
getSessionId()98         String getSessionId();
99     }
100 
setContext(Context context)101     public void setContext(Context context) {
102         mContext = context;
103     }
104 
SessionManager()105     public SessionManager() {
106     }
107 
getSessionCleanupTimeoutMs()108     private long getSessionCleanupTimeoutMs() {
109         return mSessionCleanupTimeoutMs.get();
110     }
111 
resetStaleSessionTimer()112     private synchronized void resetStaleSessionTimer() {
113         mSessionCleanupHandler.removeCallbacksAndMessages(null);
114         // Will be null in Log Testing
115         if (mCleanStaleSessions != null) {
116             mSessionCleanupHandler.postDelayed(mCleanStaleSessions, getSessionCleanupTimeoutMs());
117         }
118     }
119 
120     /**
121      * Determines whether or not to start a new session or continue an existing session based on
122      * the {@link Session.Info} info passed into startSession. If info is null, a new Session is
123      * created. This code must be accompanied by endSession() at the end of the Session.
124      */
startSession(Session.Info info, String shortMethodName, String callerIdentification)125     public synchronized void startSession(Session.Info info, String shortMethodName,
126             String callerIdentification) {
127         // Start a new session normally if the
128         if(info == null) {
129             startSession(shortMethodName, callerIdentification);
130         } else {
131             startExternalSession(info, shortMethodName);
132         }
133     }
134 
135     /**
136      * Call at an entry point to the Telecom code to track the session. This code must be
137      * accompanied by a Log.endSession().
138      */
startSession(String shortMethodName, String callerIdentification)139     public synchronized void startSession(String shortMethodName,
140             String callerIdentification) {
141         resetStaleSessionTimer();
142         int threadId = getCallingThreadId();
143         Session activeSession = mSessionMapper.get(threadId);
144         // We have called startSession within an active session that has not ended... Register this
145         // session as a subsession.
146         if (activeSession != null) {
147             Session childSession = createSubsession(true);
148             continueSession(childSession, shortMethodName);
149             return;
150         } else {
151             // Only Log that we are starting the parent session.
152             Log.d(LOGGING_TAG, Session.START_SESSION);
153         }
154         Session newSession = new Session(getNextSessionID(), shortMethodName,
155                 System.currentTimeMillis(), false, callerIdentification);
156         mSessionMapper.put(threadId, newSession);
157     }
158 
159     /**
160      * Registers an external Session with the Manager using that external Session's sessionInfo.
161      * Log.endSession will still need to be called at the end of the session.
162      * @param sessionInfo Describes the external Session's information.
163      * @param shortMethodName The method name of the new session that is being started.
164      */
startExternalSession(Session.Info sessionInfo, String shortMethodName)165     public synchronized void startExternalSession(Session.Info sessionInfo,
166             String shortMethodName) {
167         if(sessionInfo == null) {
168             return;
169         }
170 
171         int threadId = getCallingThreadId();
172         Session threadSession = mSessionMapper.get(threadId);
173         if (threadSession != null) {
174             // We should never get into a situation where there is already an active session AND
175             // an external session is added. We are just using that active session.
176             Log.w(LOGGING_TAG, "trying to start an external session with a session " +
177                     "already active.");
178             return;
179         }
180 
181         // Create Session from Info and add to the sessionMapper under this ID.
182         Log.d(LOGGING_TAG, Session.START_EXTERNAL_SESSION);
183         Session externalSession = new Session(Session.EXTERNAL_INDICATOR + sessionInfo.sessionId,
184                 sessionInfo.methodPath, System.currentTimeMillis(),
185                 false /*isStartedFromActiveSession*/, sessionInfo.ownerInfo);
186         externalSession.setIsExternal(true);
187         // Mark the external session as already completed, since we have no way of knowing when
188         // the external session actually has completed.
189         externalSession.markSessionCompleted(Session.UNDEFINED);
190         // Track the external session with the SessionMapper so that we can create and continue
191         // an active subsession based on it.
192         mSessionMapper.put(threadId, externalSession);
193         // Create a subsession from this external Session parent node
194         Session childSession = createSubsession();
195         continueSession(childSession, shortMethodName);
196     }
197 
198     /**
199      * Notifies the logging system that a subsession will be run at a later point and
200      * allocates the resources. Returns a session object that must be used in
201      * Log.continueSession(...) to start the subsession.
202      */
createSubsession()203     public Session createSubsession() {
204         return createSubsession(false);
205     }
206 
207     /**
208      * Creates a new subsession based on an existing session. Will not be started until
209      * {@link #continueSession(Session, String)} or {@link #cancelSubsession(Session)} is called.
210      * <p>
211      * Only public for testing!
212      * @param isStartedFromActiveSession true if this subsession is being created for a task on the
213      *     same thread, false if it is being created for a related task on another thread.
214      * @return a new {@link Session}, call {@link #continueSession(Session, String)} to continue the
215      * session and {@link #endSession()} when done with this subsession.
216      */
217     @VisibleForTesting
createSubsession(boolean isStartedFromActiveSession)218     public synchronized Session createSubsession(boolean isStartedFromActiveSession) {
219         int threadId = getCallingThreadId();
220         Session threadSession = mSessionMapper.get(threadId);
221         if (threadSession == null) {
222             Log.d(LOGGING_TAG, "Log.createSubsession was called with no session " +
223                     "active.");
224             return null;
225         }
226         // Start execution time of the session will be overwritten in continueSession(...).
227         Session newSubsession = new Session(threadSession.getNextChildId(),
228                 threadSession.getShortMethodName(), System.currentTimeMillis(),
229                 isStartedFromActiveSession, threadSession.getOwnerInfo());
230         threadSession.addChild(newSubsession);
231         newSubsession.setParentSession(threadSession);
232 
233         if (!isStartedFromActiveSession) {
234             Log.v(LOGGING_TAG, Session.CREATE_SUBSESSION + " " +
235                     newSubsession.toString());
236         } else {
237             Log.v(LOGGING_TAG, Session.CREATE_SUBSESSION +
238                     " (Invisible subsession)");
239         }
240         return newSubsession;
241     }
242 
getExternalSession()243     public synchronized Session.Info getExternalSession() {
244         return getExternalSession(null /* ownerInfo */);
245     }
246 
247     /**
248      * Retrieve the information of the currently active Session. This information is parcelable and
249      * is used to create an external Session ({@link #startExternalSession(Session.Info, String)}).
250      * If there is no Session active, this method will return null.
251      * @param ownerInfo Owner information for the session.
252      * @return The session information
253      */
getExternalSession(@ullable String ownerInfo)254     public synchronized Session.Info getExternalSession(@Nullable String ownerInfo) {
255         int threadId = getCallingThreadId();
256         Session threadSession = mSessionMapper.get(threadId);
257         if (threadSession == null) {
258             Log.d(LOGGING_TAG, "Log.getExternalSession was called with no session " +
259                     "active.");
260             return null;
261         }
262         return threadSession.getExternalInfo(ownerInfo);
263     }
264 
265     /**
266      * Cancels a subsession that had Log.createSubsession() called on it, but will never have
267      * Log.continueSession(...) called on it due to an error. Allows the subsession to be cleaned
268      * gracefully instead of being removed by the mSessionCleanupHandler forcefully later.
269      */
cancelSubsession(Session subsession)270     public synchronized void cancelSubsession(Session subsession) {
271         if (subsession == null) {
272             return;
273         }
274 
275         subsession.markSessionCompleted(Session.UNDEFINED);
276         endParentSessions(subsession);
277     }
278 
279     /**
280      * Starts the subsession that was created in Log.CreateSubsession. The Log.endSession() method
281      * must be called at the end of this method. The full session will complete when all
282      * subsessions are completed.
283      */
continueSession(Session subsession, String shortMethodName)284     public synchronized void continueSession(Session subsession, String shortMethodName) {
285         if (subsession == null) {
286             return;
287         }
288         resetStaleSessionTimer();
289         subsession.setShortMethodName(shortMethodName);
290         subsession.setExecutionStartTimeMs(System.currentTimeMillis());
291         Session parentSession = subsession.getParentSession();
292         if (parentSession == null) {
293             Log.i(LOGGING_TAG, "Log.continueSession was called with no session " +
294                     "active for method " + shortMethodName);
295             return;
296         }
297 
298         mSessionMapper.put(getCallingThreadId(), subsession);
299         if (!subsession.isStartedFromActiveSession()) {
300             Log.v(LOGGING_TAG, Session.CONTINUE_SUBSESSION);
301         } else {
302             Log.v(LOGGING_TAG, Session.CONTINUE_SUBSESSION +
303                     " (Invisible Subsession) with Method " + shortMethodName);
304         }
305     }
306 
307     /**
308      * Ends the current session/subsession. Must be called after a Log.startSession(...) and
309      * Log.continueSession(...) call.
310      */
endSession()311     public synchronized void endSession() {
312         int threadId = getCallingThreadId();
313         Session completedSession = mSessionMapper.get(threadId);
314         if (completedSession == null) {
315             Log.w(LOGGING_TAG, "Log.endSession was called with no session active.");
316             return;
317         }
318 
319         completedSession.markSessionCompleted(System.currentTimeMillis());
320         if (!completedSession.isStartedFromActiveSession()) {
321             Log.v(LOGGING_TAG, Session.END_SUBSESSION + " (dur: " +
322                     completedSession.getLocalExecutionTime() + " mS)");
323         } else {
324             Log.v(LOGGING_TAG, Session.END_SUBSESSION +
325                     " (Invisible Subsession) (dur: " + completedSession.getLocalExecutionTime() +
326                     " ms)");
327         }
328         // Remove after completed so that reference still exists for logging the end events
329         Session parentSession = completedSession.getParentSession();
330         mSessionMapper.remove(threadId);
331         endParentSessions(completedSession);
332         // If this subsession was started from a parent session using Log.startSession, return the
333         // ThreadID back to the parent after completion.
334         if (parentSession != null && !parentSession.isSessionCompleted() &&
335                 completedSession.isStartedFromActiveSession()) {
336             mSessionMapper.put(threadId, parentSession);
337         }
338     }
339 
340     // Recursively deletes all complete parent sessions of the current subsession if it is a leaf.
endParentSessions(Session subsession)341     private void endParentSessions(Session subsession) {
342         // Session is not completed or not currently a leaf, so we can not remove because a child is
343         // still running
344         if (!subsession.isSessionCompleted() || subsession.getChildSessions().size() != 0) {
345             return;
346         }
347         Session parentSession = subsession.getParentSession();
348         if (parentSession != null) {
349             subsession.setParentSession(null);
350             parentSession.removeChild(subsession);
351             // Report the child session of the external session as being complete to the listeners,
352             // not the external session itself.
353             if (parentSession.isExternal()) {
354                 long fullSessionTimeMs =
355                         System.currentTimeMillis() - subsession.getExecutionStartTimeMilliseconds();
356                 notifySessionCompleteListeners(subsession.getShortMethodName(), fullSessionTimeMs);
357             }
358             endParentSessions(parentSession);
359         } else {
360             // All of the subsessions have been completed and it is time to report on the full
361             // running time of the session.
362             long fullSessionTimeMs =
363                     System.currentTimeMillis() - subsession.getExecutionStartTimeMilliseconds();
364             Log.d(LOGGING_TAG, Session.END_SESSION + " (dur: " + fullSessionTimeMs
365                     + " ms): " + subsession.toString());
366             if (!subsession.isExternal()) {
367                 notifySessionCompleteListeners(subsession.getShortMethodName(), fullSessionTimeMs);
368             }
369         }
370     }
371 
notifySessionCompleteListeners(String methodName, long sessionTimeMs)372     private void notifySessionCompleteListeners(String methodName, long sessionTimeMs) {
373         for (ISessionListener l : mSessionListeners) {
374             l.sessionComplete(methodName, sessionTimeMs);
375         }
376     }
377 
getSessionId()378     public String getSessionId() {
379         Session currentSession = mSessionMapper.get(getCallingThreadId());
380         return currentSession != null ? currentSession.toString() : "";
381     }
382 
registerSessionListener(ISessionListener l)383     public synchronized void registerSessionListener(ISessionListener l) {
384         if (l != null) {
385             mSessionListeners.add(l);
386         }
387     }
388 
getNextSessionID()389     private synchronized String getNextSessionID() {
390         Integer nextId = sCodeEntryCounter++;
391         if (nextId >= SESSION_ID_ROLLOVER_THRESHOLD) {
392             restartSessionCounter();
393             nextId = sCodeEntryCounter++;
394         }
395         return getBase64Encoding(nextId);
396     }
397 
restartSessionCounter()398     private synchronized void restartSessionCounter() {
399         sCodeEntryCounter = 0;
400     }
401 
getBase64Encoding(int number)402     private String getBase64Encoding(int number) {
403         byte[] idByteArray = ByteBuffer.allocate(4).putInt(number).array();
404         idByteArray = Arrays.copyOfRange(idByteArray, 2, 4);
405         return Base64.encodeToString(idByteArray, Base64.NO_WRAP | Base64.NO_PADDING);
406     }
407 
getCallingThreadId()408     private int getCallingThreadId() {
409         return mCurrentThreadId.get();
410     }
411 
412     /**
413      * @return A String representation of the active sessions at the time that this method is
414      * called.
415      */
416     @VisibleForTesting
printActiveSessions()417     public synchronized String printActiveSessions() {
418         StringBuilder message = new StringBuilder();
419         for (ConcurrentHashMap.Entry<Integer, Session> entry : mSessionMapper.entrySet()) {
420             message.append(entry.getValue().printFullSessionTree());
421             message.append("\n");
422         }
423         return message.toString();
424     }
425 
426     @VisibleForTesting
cleanupStaleSessions(long timeoutMs)427     public synchronized void cleanupStaleSessions(long timeoutMs) {
428         String logMessage = "Stale Sessions Cleaned:\n";
429         boolean isSessionsStale = false;
430         long currentTimeMs = System.currentTimeMillis();
431         // Remove references that are in the Session Mapper (causing GC to occur) on
432         // sessions that are lasting longer than LOGGING_SESSION_TIMEOUT_MS.
433         // If this occurs, then there is most likely a Session active that never had
434         // Log.endSession called on it.
435         for (Iterator<ConcurrentHashMap.Entry<Integer, Session>> it =
436              mSessionMapper.entrySet().iterator(); it.hasNext(); ) {
437             ConcurrentHashMap.Entry<Integer, Session> entry = it.next();
438             Session session = entry.getValue();
439             if (currentTimeMs - session.getExecutionStartTimeMilliseconds() > timeoutMs) {
440                 it.remove();
441                 logMessage += session.printFullSessionTree() + "\n";
442                 isSessionsStale = true;
443             }
444         }
445         if (isSessionsStale) {
446             Log.w(LOGGING_TAG, logMessage);
447         } else {
448             Log.v(LOGGING_TAG, "No stale logging sessions needed to be cleaned...");
449         }
450     }
451 
452     /**
453      * Returns the amount of time after a Logging session has been started that Telecom is set to
454      * perform a sweep to check and make sure that the session is still not incomplete (stale).
455      */
getCleanupTimeout(Context context)456     private long getCleanupTimeout(Context context) {
457         final ContentResolver cr = context.getContentResolver();
458         return Settings.Secure.getLongForUser(cr, TIMEOUTS_PREFIX
459                         + "stale_session_cleanup_timeout_millis", DEFAULT_SESSION_TIMEOUT_MS,
460                 cr.getUserId());
461     }
462 }
463