| /* |
| * Copyright (C) 2021 The Android Open Source Project |
| * |
| * Licensed under the Apache License, Version 2.0 (the "License"); |
| * you may not use this file except in compliance with the License. |
| * You may obtain a copy of the License at |
| * |
| * http://www.apache.org/licenses/LICENSE-2.0 |
| * |
| * Unless required by applicable law or agreed to in writing, software |
| * distributed under the License is distributed on an "AS IS" BASIS, |
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| * See the License for the specific language governing permissions and |
| * limitations under the License. |
| */ |
| |
| package com.android.server.appsearch.stats; |
| |
| import android.annotation.NonNull; |
| import android.annotation.Nullable; |
| import android.app.appsearch.exceptions.AppSearchException; |
| import android.content.Context; |
| import android.os.Process; |
| import android.os.SystemClock; |
| import android.util.ArrayMap; |
| import android.util.Log; |
| import android.util.SparseIntArray; |
| |
| import com.android.internal.annotations.GuardedBy; |
| import com.android.internal.annotations.VisibleForTesting; |
| import com.android.server.appsearch.AppSearchConfig; |
| import com.android.server.appsearch.external.localstorage.AppSearchLogger; |
| import com.android.server.appsearch.external.localstorage.stats.CallStats; |
| import com.android.server.appsearch.external.localstorage.stats.InitializeStats; |
| import com.android.server.appsearch.external.localstorage.stats.OptimizeStats; |
| import com.android.server.appsearch.external.localstorage.stats.PutDocumentStats; |
| import com.android.server.appsearch.external.localstorage.stats.RemoveStats; |
| import com.android.server.appsearch.external.localstorage.stats.SearchStats; |
| import com.android.server.appsearch.external.localstorage.stats.SetSchemaStats; |
| import com.android.server.appsearch.util.PackageUtil; |
| |
| import java.io.UnsupportedEncodingException; |
| import java.security.MessageDigest; |
| import java.security.NoSuchAlgorithmException; |
| import java.util.Map; |
| import java.util.Objects; |
| import java.util.Random; |
| |
| /** |
| * Logger Implementation for pushed atoms. |
| * |
| * <p>This class is thread-safe. |
| * |
| * @hide |
| */ |
| public final class PlatformLogger implements AppSearchLogger { |
| private static final String TAG = "AppSearchPlatformLogger"; |
| |
| // Context of the user we're logging for. |
| private final Context mUserContext; |
| |
| // Manager holding the configuration flags |
| private final AppSearchConfig mConfig; |
| |
| private final Random mRng = new Random(); |
| private final Object mLock = new Object(); |
| |
| /** |
| * SparseArray to track how many stats we skipped due to |
| * {@link AppSearchConfig#getCachedMinTimeIntervalBetweenSamplesMillis()}. |
| * |
| * <p> We can have correct extrapolated number by adding those counts back when we log |
| * the same type of stats next time. E.g. the true count of an event could be estimated as: |
| * SUM(sampling_interval * (num_skipped_sample + 1)) as est_count |
| * |
| * <p>The key to the SparseArray is {@link CallStats.CallType} |
| */ |
| @GuardedBy("mLock") |
| private final SparseIntArray mSkippedSampleCountLocked = |
| new SparseIntArray(); |
| |
| /** |
| * Map to cache the packageUid for each package. |
| * |
| * <p>It maps packageName to packageUid. |
| * |
| * <p>The entry will be removed whenever the app gets uninstalled |
| */ |
| @GuardedBy("mLock") |
| private final Map<String, Integer> mPackageUidCacheLocked = |
| new ArrayMap<>(); |
| |
| /** |
| * Elapsed time for last stats logged from boot in millis |
| */ |
| @GuardedBy("mLock") |
| private long mLastPushTimeMillisLocked = 0; |
| |
| /** |
| * Helper class to hold platform specific stats for statsd. |
| */ |
| static final class ExtraStats { |
| // UID for the calling package of the stats. |
| final int mPackageUid; |
| // sampling interval for the call type of the stats. |
| final int mSamplingInterval; |
| // number of samplings skipped before the current one for the same call type. |
| final int mSkippedSampleCount; |
| |
| ExtraStats(int packageUid, int samplingInterval, int skippedSampleCount) { |
| mPackageUid = packageUid; |
| mSamplingInterval = samplingInterval; |
| mSkippedSampleCount = skippedSampleCount; |
| } |
| } |
| |
| /** |
| * Constructor |
| */ |
| public PlatformLogger( |
| @NonNull Context userContext, |
| @NonNull AppSearchConfig config) { |
| mUserContext = Objects.requireNonNull(userContext); |
| mConfig = Objects.requireNonNull(config); |
| } |
| |
| /** Logs {@link CallStats}. */ |
| @Override |
| public void logStats(@NonNull CallStats stats) { |
| Objects.requireNonNull(stats); |
| synchronized (mLock) { |
| if (shouldLogForTypeLocked(stats.getCallType())) { |
| logStatsImplLocked(stats); |
| } |
| } |
| } |
| |
| /** Logs {@link PutDocumentStats}. */ |
| @Override |
| public void logStats(@NonNull PutDocumentStats stats) { |
| Objects.requireNonNull(stats); |
| synchronized (mLock) { |
| if (shouldLogForTypeLocked(CallStats.CALL_TYPE_PUT_DOCUMENT)) { |
| logStatsImplLocked(stats); |
| } |
| } |
| } |
| |
| @Override |
| public void logStats(@NonNull InitializeStats stats) { |
| Objects.requireNonNull(stats); |
| synchronized (mLock) { |
| if (shouldLogForTypeLocked(CallStats.CALL_TYPE_INITIALIZE)) { |
| logStatsImplLocked(stats); |
| } |
| } |
| } |
| |
| @Override |
| public void logStats(@NonNull SearchStats stats) { |
| Objects.requireNonNull(stats); |
| synchronized (mLock) { |
| if (shouldLogForTypeLocked(CallStats.CALL_TYPE_SEARCH)) { |
| logStatsImplLocked(stats); |
| } |
| } |
| } |
| |
| @Override |
| public void logStats(@NonNull RemoveStats stats) { |
| // TODO(b/173532925): Log stats |
| } |
| |
| @Override |
| public void logStats(@NonNull OptimizeStats stats) { |
| Objects.requireNonNull(stats); |
| synchronized (mLock) { |
| if (shouldLogForTypeLocked(CallStats.CALL_TYPE_OPTIMIZE)) { |
| logStatsImplLocked(stats); |
| } |
| } |
| } |
| |
| @Override |
| public void logStats(@NonNull SetSchemaStats stats) { |
| // TODO(b/173532925): Log stats |
| } |
| |
| /** |
| * Removes cached UID for package. |
| * |
| * @return removed UID for the package, or {@code INVALID_UID} if package was not previously |
| * cached. |
| */ |
| public int removeCachedUidForPackage(@NonNull String packageName) { |
| // TODO(b/173532925) This needs to be called when we get PACKAGE_REMOVED intent |
| Objects.requireNonNull(packageName); |
| synchronized (mLock) { |
| Integer uid = mPackageUidCacheLocked.remove(packageName); |
| return uid != null ? uid : Process.INVALID_UID; |
| } |
| } |
| |
| @GuardedBy("mLock") |
| private void logStatsImplLocked(@NonNull CallStats stats) { |
| mLastPushTimeMillisLocked = SystemClock.elapsedRealtime(); |
| ExtraStats extraStats = createExtraStatsLocked(stats.getPackageName(), stats.getCallType()); |
| String database = stats.getDatabase(); |
| try { |
| int hashCodeForDatabase = calculateHashCodeMd5(database); |
| AppSearchStatsLog.write(AppSearchStatsLog.APP_SEARCH_CALL_STATS_REPORTED, |
| extraStats.mSamplingInterval, |
| extraStats.mSkippedSampleCount, |
| extraStats.mPackageUid, |
| hashCodeForDatabase, |
| stats.getStatusCode(), |
| stats.getTotalLatencyMillis(), |
| stats.getCallType(), |
| stats.getEstimatedBinderLatencyMillis(), |
| stats.getNumOperationsSucceeded(), |
| stats.getNumOperationsFailed()); |
| } catch (NoSuchAlgorithmException | UnsupportedEncodingException e) { |
| // TODO(b/184204720) report hashing error to statsd |
| // We need to set a special value(e.g. 0xFFFFFFFF) for the hashing of the database, |
| // so in the dashboard we know there is some error for hashing. |
| // |
| // Something is wrong while calculating the hash code for database |
| // this shouldn't happen since we always use "MD5" and "UTF-8" |
| if (database != null) { |
| Log.e(TAG, "Error calculating hash code for database " + database, e); |
| } |
| } |
| } |
| |
| @GuardedBy("mLock") |
| private void logStatsImplLocked(@NonNull PutDocumentStats stats) { |
| mLastPushTimeMillisLocked = SystemClock.elapsedRealtime(); |
| ExtraStats extraStats = createExtraStatsLocked( |
| stats.getPackageName(), CallStats.CALL_TYPE_PUT_DOCUMENT); |
| String database = stats.getDatabase(); |
| try { |
| int hashCodeForDatabase = calculateHashCodeMd5(database); |
| AppSearchStatsLog.write(AppSearchStatsLog.APP_SEARCH_PUT_DOCUMENT_STATS_REPORTED, |
| extraStats.mSamplingInterval, |
| extraStats.mSkippedSampleCount, |
| extraStats.mPackageUid, |
| hashCodeForDatabase, |
| stats.getStatusCode(), |
| stats.getTotalLatencyMillis(), |
| stats.getGenerateDocumentProtoLatencyMillis(), |
| stats.getRewriteDocumentTypesLatencyMillis(), |
| stats.getNativeLatencyMillis(), |
| stats.getNativeDocumentStoreLatencyMillis(), |
| stats.getNativeIndexLatencyMillis(), |
| stats.getNativeIndexMergeLatencyMillis(), |
| stats.getNativeDocumentSizeBytes(), |
| stats.getNativeNumTokensIndexed(), |
| stats.getNativeExceededMaxNumTokens()); |
| } catch (NoSuchAlgorithmException | UnsupportedEncodingException e) { |
| // TODO(b/184204720) report hashing error to statsd |
| // We need to set a special value(e.g. 0xFFFFFFFF) for the hashing of the database, |
| // so in the dashboard we know there is some error for hashing. |
| // |
| // Something is wrong while calculating the hash code for database |
| // this shouldn't happen since we always use "MD5" and "UTF-8" |
| if (database != null) { |
| Log.e(TAG, "Error calculating hash code for database " + database, e); |
| } |
| } |
| } |
| |
| @GuardedBy("mLock") |
| private void logStatsImplLocked(@NonNull SearchStats stats) { |
| mLastPushTimeMillisLocked = SystemClock.elapsedRealtime(); |
| ExtraStats extraStats = createExtraStatsLocked(stats.getPackageName(), |
| CallStats.CALL_TYPE_SEARCH); |
| String database = stats.getDatabase(); |
| try { |
| int hashCodeForDatabase = calculateHashCodeMd5(database); |
| AppSearchStatsLog.write(AppSearchStatsLog.APP_SEARCH_QUERY_STATS_REPORTED, |
| extraStats.mSamplingInterval, |
| extraStats.mSkippedSampleCount, |
| extraStats.mPackageUid, |
| hashCodeForDatabase, |
| stats.getStatusCode(), |
| stats.getTotalLatencyMillis(), |
| stats.getRewriteSearchSpecLatencyMillis(), |
| stats.getRewriteSearchResultLatencyMillis(), |
| stats.getVisibilityScope(), |
| stats.getNativeLatencyMillis(), |
| stats.getTermCount(), |
| stats.getQueryLength(), |
| stats.getFilteredNamespaceCount(), |
| stats.getFilteredSchemaTypeCount(), |
| stats.getRequestedPageSize(), |
| stats.getCurrentPageReturnedResultCount(), |
| stats.isFirstPage(), |
| stats.getParseQueryLatencyMillis(), |
| stats.getRankingStrategy(), |
| stats.getScoredDocumentCount(), |
| stats.getScoringLatencyMillis(), |
| stats.getRankingLatencyMillis(), |
| stats.getDocumentRetrievingLatencyMillis(), |
| stats.getResultWithSnippetsCount()); |
| } catch (NoSuchAlgorithmException | UnsupportedEncodingException e) { |
| // TODO(b/184204720) report hashing error to statsd |
| // We need to set a special value(e.g. 0xFFFFFFFF) for the hashing of the database, |
| // so in the dashboard we know there is some error for hashing. |
| // |
| // Something is wrong while calculating the hash code for database |
| // this shouldn't happen since we always use "MD5" and "UTF-8" |
| if (database != null) { |
| Log.e(TAG, "Error calculating hash code for database " + database, e); |
| } |
| } |
| } |
| |
| @GuardedBy("mLock") |
| private void logStatsImplLocked(@NonNull InitializeStats stats) { |
| mLastPushTimeMillisLocked = SystemClock.elapsedRealtime(); |
| ExtraStats extraStats = createExtraStatsLocked(/*packageName=*/ null, |
| CallStats.CALL_TYPE_INITIALIZE); |
| AppSearchStatsLog.write(AppSearchStatsLog.APP_SEARCH_INITIALIZE_STATS_REPORTED, |
| extraStats.mSamplingInterval, |
| extraStats.mSkippedSampleCount, |
| extraStats.mPackageUid, |
| stats.getStatusCode(), |
| stats.getTotalLatencyMillis(), |
| stats.hasDeSync(), |
| stats.getPrepareSchemaAndNamespacesLatencyMillis(), |
| stats.getPrepareVisibilityStoreLatencyMillis(), |
| stats.getNativeLatencyMillis(), |
| stats.getDocumentStoreRecoveryCause(), |
| stats.getIndexRestorationCause(), |
| stats.getSchemaStoreRecoveryCause(), |
| stats.getDocumentStoreRecoveryLatencyMillis(), |
| stats.getIndexRestorationLatencyMillis(), |
| stats.getSchemaStoreRecoveryLatencyMillis(), |
| stats.getDocumentStoreDataStatus(), |
| stats.getDocumentCount(), |
| stats.getSchemaTypeCount(), |
| stats.hasReset(), |
| stats.getResetStatusCode()); |
| } |
| |
| @GuardedBy("mLock") |
| private void logStatsImplLocked(@NonNull OptimizeStats stats) { |
| mLastPushTimeMillisLocked = SystemClock.elapsedRealtime(); |
| ExtraStats extraStats = createExtraStatsLocked(/*packageName=*/ null, |
| CallStats.CALL_TYPE_OPTIMIZE); |
| AppSearchStatsLog.write(AppSearchStatsLog.APP_SEARCH_OPTIMIZE_STATS_REPORTED, |
| extraStats.mSamplingInterval, |
| extraStats.mSkippedSampleCount, |
| stats.getStatusCode(), |
| stats.getTotalLatencyMillis(), |
| stats.getNativeLatencyMillis(), |
| stats.getDocumentStoreOptimizeLatencyMillis(), |
| stats.getIndexRestorationLatencyMillis(), |
| stats.getOriginalDocumentCount(), |
| stats.getDeletedDocumentCount(), |
| stats.getExpiredDocumentCount(), |
| stats.getStorageSizeBeforeBytes(), |
| stats.getStorageSizeAfterBytes(), |
| stats.getTimeSinceLastOptimizeMillis()); |
| } |
| |
| /** |
| * Calculate the hash code as an integer by returning the last four bytes of its MD5. |
| * |
| * @param str a string |
| * @return hash code as an integer. returns -1 if str is null. |
| * @throws AppSearchException if either algorithm or encoding does not exist. |
| */ |
| @VisibleForTesting |
| @NonNull |
| static int calculateHashCodeMd5(@Nullable String str) throws |
| NoSuchAlgorithmException, UnsupportedEncodingException { |
| if (str == null) { |
| // Just return -1 if caller doesn't have database name |
| // For some stats like globalQuery, databaseName can be null. |
| // Since in atom it is an integer, we have to return something here. |
| return -1; |
| } |
| |
| MessageDigest md = MessageDigest.getInstance("MD5"); |
| md.update(str.getBytes(/*charsetName=*/ "UTF-8")); |
| byte[] digest = md.digest(); |
| |
| // Since MD5 generates 16 bytes digest, we don't need to check the length here to see |
| // if it is smaller than sizeof(int)(4). |
| // |
| // We generate the same value as BigInteger(digest).intValue(). |
| // BigInteger takes bytes[] and treat it as big endian. And its intValue() would get the |
| // lower 4 bytes. So here we take the last 4 bytes and treat them as big endian. |
| return (digest[12] & 0xFF) << 24 |
| | (digest[13] & 0xFF) << 16 |
| | (digest[14] & 0xFF) << 8 |
| | (digest[15] & 0xFF); |
| } |
| |
| /** |
| * Creates {@link ExtraStats} to hold additional information generated for logging. |
| * |
| * <p>This method is called by most of logStatsImplLocked functions to reduce code |
| * duplication. |
| */ |
| // TODO(b/173532925) Once we add CTS test for logging atoms and can inspect the result, we can |
| // remove this @VisibleForTesting and directly use PlatformLogger.logStats to test sampling and |
| // rate limiting. |
| @VisibleForTesting |
| @GuardedBy("mLock") |
| @NonNull |
| ExtraStats createExtraStatsLocked(@Nullable String packageName, |
| @CallStats.CallType int callType) { |
| int packageUid = Process.INVALID_UID; |
| if (packageName != null) { |
| packageUid = getPackageUidAsUserLocked(packageName); |
| } |
| |
| // The sampling ratio here might be different from the one used in |
| // shouldLogForTypeLocked if there is a config change in the middle. |
| // Since it is only one sample, we can just ignore this difference. |
| // Or we can retrieve samplingRatio at beginning and pass along |
| // as function parameter, but it will make code less cleaner with some duplication. |
| int samplingInterval = getSamplingIntervalFromConfig(callType); |
| int skippedSampleCount = mSkippedSampleCountLocked.get(callType, |
| /*valueOfKeyIfNotFound=*/ 0); |
| mSkippedSampleCountLocked.put(callType, 0); |
| |
| return new ExtraStats(packageUid, samplingInterval, skippedSampleCount); |
| } |
| |
| /** |
| * Checks if this stats should be logged. |
| * |
| * <p>It won't be logged if it is "sampled" out, or it is too close to the previous logged |
| * stats. |
| */ |
| @GuardedBy("mLock") |
| // TODO(b/173532925) Once we add CTS test for logging atoms and can inspect the result, we can |
| // remove this @VisibleForTesting and directly use PlatformLogger.logStats to test sampling and |
| // rate limiting. |
| @VisibleForTesting |
| boolean shouldLogForTypeLocked(@CallStats.CallType int callType) { |
| int samplingInterval = getSamplingIntervalFromConfig(callType); |
| // Sampling |
| if (!shouldSample(samplingInterval)) { |
| return false; |
| } |
| |
| // Rate limiting |
| // Check the timestamp to see if it is too close to last logged sample |
| long currentTimeMillis = SystemClock.elapsedRealtime(); |
| if (mLastPushTimeMillisLocked |
| > currentTimeMillis - mConfig.getCachedMinTimeIntervalBetweenSamplesMillis()) { |
| int count = mSkippedSampleCountLocked.get(callType, /*valueOfKeyIfNotFound=*/ 0); |
| ++count; |
| mSkippedSampleCountLocked.put(callType, count); |
| return false; |
| } |
| |
| return true; |
| } |
| |
| /** |
| * Checks if the stats should be "sampled" |
| * |
| * @param samplingInterval sampling interval |
| * @return if the stats should be sampled |
| */ |
| private boolean shouldSample(int samplingInterval) { |
| if (samplingInterval <= 0) { |
| return false; |
| } |
| |
| return mRng.nextInt((int) samplingInterval) == 0; |
| } |
| |
| /** |
| * Finds the UID of the {@code packageName}. Returns {@link Process#INVALID_UID} if unable to |
| * find the UID. |
| */ |
| @GuardedBy("mLock") |
| private int getPackageUidAsUserLocked(@NonNull String packageName) { |
| Integer packageUid = mPackageUidCacheLocked.get(packageName); |
| if (packageUid == null) { |
| packageUid = PackageUtil.getPackageUid(mUserContext, packageName); |
| if (packageUid != Process.INVALID_UID) { |
| mPackageUidCacheLocked.put(packageName, packageUid); |
| } |
| } |
| return packageUid; |
| } |
| |
| /** Returns sampling ratio for stats type specified form {@link AppSearchConfig}. */ |
| private int getSamplingIntervalFromConfig(@CallStats.CallType int statsType) { |
| switch (statsType) { |
| case CallStats.CALL_TYPE_PUT_DOCUMENTS: |
| case CallStats.CALL_TYPE_GET_DOCUMENTS: |
| case CallStats.CALL_TYPE_REMOVE_DOCUMENTS_BY_ID: |
| case CallStats.CALL_TYPE_REMOVE_DOCUMENTS_BY_SEARCH: |
| return mConfig.getCachedSamplingIntervalForBatchCallStats(); |
| case CallStats.CALL_TYPE_PUT_DOCUMENT: |
| return mConfig.getCachedSamplingIntervalForPutDocumentStats(); |
| case CallStats.CALL_TYPE_INITIALIZE: |
| return mConfig.getCachedSamplingIntervalForInitializeStats(); |
| case CallStats.CALL_TYPE_SEARCH: |
| return mConfig.getCachedSamplingIntervalForSearchStats(); |
| case CallStats.CALL_TYPE_GLOBAL_SEARCH: |
| return mConfig.getCachedSamplingIntervalForGlobalSearchStats(); |
| case CallStats.CALL_TYPE_OPTIMIZE: |
| return mConfig.getCachedSamplingIntervalForOptimizeStats(); |
| case CallStats.CALL_TYPE_UNKNOWN: |
| case CallStats.CALL_TYPE_SET_SCHEMA: |
| case CallStats.CALL_TYPE_GET_DOCUMENT: |
| case CallStats.CALL_TYPE_REMOVE_DOCUMENT_BY_ID: |
| case CallStats.CALL_TYPE_FLUSH: |
| case CallStats.CALL_TYPE_REMOVE_DOCUMENT_BY_SEARCH: |
| // TODO(b/173532925) Some of them above will have dedicated sampling ratio config |
| default: |
| return mConfig.getCachedSamplingIntervalDefault(); |
| } |
| } |
| |
| // |
| // Functions below are used for tests only |
| // |
| @VisibleForTesting |
| @GuardedBy("mLock") |
| void setLastPushTimeMillisLocked(long lastPushElapsedTimeMillis) { |
| mLastPushTimeMillisLocked = lastPushElapsedTimeMillis; |
| } |
| } |