blob: 5f9658d1445ce7f24f4b13e5902ba1da5fb3558d [file] [log] [blame]
// Copyright 2020 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
package org.chromium.base.metrics;
import android.annotation.SuppressLint;
import androidx.annotation.IntDef;
import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;
import org.chromium.base.Callback;
import org.chromium.base.Log;
import org.chromium.build.BuildConfig;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.locks.ReentrantReadWriteLock;
import javax.annotation.concurrent.GuardedBy;
/**
* Stores metrics until given an {@link UmaRecorder} to forward the samples to. After flushing, no
* longer stores metrics, instead immediately forwards them to the given {@link UmaRecorder}.
*/
/* package */ final class CachingUmaRecorder implements UmaRecorder {
private static final String TAG = "CachingUmaRecorder";
/**
* Maximum number of histograms cached at the same time. It is better to drop some samples
* rather than have a bug cause the cache to grow without limit.
* <p>
* Each sample uses 4 bytes, each histogram uses approx. 12 references (at least 4 bytes each).
* With {@code MAX_HISTOGRAM_COUNT = 256} and {@code MAX_SAMPLE_COUNT = 256} this limits cache
* size to 270KiB. Changing either value by one, adds or removes approx. 1KiB.
*/
private static final int MAX_HISTOGRAM_COUNT = 256;
/**
* Maximum number of user actions cached at the same time. It is better to drop some samples
* rather than have a bug cause the cache to grow without limit.
*/
@VisibleForTesting
static final int MAX_USER_ACTION_COUNT = 256;
/** Stores the definition and samples of a single cached histogram. */
@VisibleForTesting
static class Histogram {
/**
* Maximum number of cached samples in a single histogram. it is better to drop some
* samples rather than have a bug cause the cache to grow without limit
*/
@VisibleForTesting
static final int MAX_SAMPLE_COUNT = 256;
/**
* Identifies the type of the histogram.
*/
@IntDef({
Type.BOOLEAN,
Type.EXPONENTIAL,
Type.LINEAR,
Type.SPARSE,
})
@Retention(RetentionPolicy.SOURCE)
@interface Type {
/**
* Used by histograms recorded with {@link UmaRecorder#recordBooleanHistogram}.
*/
int BOOLEAN = 1;
/**
* Used by histograms recorded with {@link UmaRecorder#recordExponentialHistogram}.
*/
int EXPONENTIAL = 2;
/**
* Used by histograms recorded with {@link UmaRecorder#recordLinearHistogram}.
*/
int LINEAR = 3;
/**
* Used by histograms recorded with {@link UmaRecorder#recordSparseHistogram}.
*/
int SPARSE = 4;
}
@Type
private final int mType;
private final String mName;
private final int mMin;
private final int mMax;
private final int mNumBuckets;
@GuardedBy("this")
private final List<Integer> mSamples;
/**
* Constructs a {@code Histogram} with the specified definition and no samples.
*
* @param type histogram type.
* @param name histogram name.
* @param min histogram min value. Must be {@code 0} for boolean or sparse histograms.
* @param max histogram max value. Must be {@code 0} for boolean or sparse histograms.
* @param numBuckets number of histogram buckets. Must be {@code 0} for boolean or sparse
* histograms.
*/
Histogram(@Type int type, String name, int min, int max, int numBuckets) {
assert type == Type.EXPONENTIAL || type == Type.LINEAR
|| (min == 0 && max == 0 && numBuckets == 0)
: "Histogram type " + type + " must have no min/max/buckets set";
mType = type;
mName = name;
mMin = min;
mMax = max;
mNumBuckets = numBuckets;
mSamples = new ArrayList<>(/*initialCapacity=*/1);
}
/**
* Appends a sample to values cached in this histogram. Verifies that histogram definition
* matches the definition used to create this object: attempts to fail with an assertion,
* otherwise records failure statistics.
*
* @param type histogram type.
* @param name histogram name.
* @param sample sample value to cache.
* @param min histogram min value. Must be {@code 0} for boolean or sparse histograms.
* @param max histogram max value. Must be {@code 0} for boolean or sparse histograms.
* @param numBuckets number of histogram buckets. Must be {@code 0} for boolean or sparse
* histograms.
* @return true if the sample was recorded.
*/
synchronized boolean addSample(
@Type int type, String name, int sample, int min, int max, int numBuckets) {
assert mType == type;
assert mName.equals(name);
assert mMin == min;
assert mMax == max;
assert mNumBuckets == numBuckets;
if (mSamples.size() >= MAX_SAMPLE_COUNT) {
// A cache filling up is most likely an indication of a bug.
assert false : "Histogram exceeded sample cache size limit";
return false;
}
mSamples.add(sample);
return true;
}
/**
* Writes all histogram samples to {@code recorder}, clears the cache.
*
* @param recorder destination {@link UmaRecorder}.
* @return number of flushed histogram samples.
*/
synchronized int flushTo(UmaRecorder recorder) {
switch (mType) {
case Type.BOOLEAN:
for (int i = 0; i < mSamples.size(); i++) {
final int sample = mSamples.get(i);
recorder.recordBooleanHistogram(mName, sample != 0);
}
break;
case Type.EXPONENTIAL:
for (int i = 0; i < mSamples.size(); i++) {
final int sample = mSamples.get(i);
recorder.recordExponentialHistogram(mName, sample, mMin, mMax, mNumBuckets);
}
break;
case Type.LINEAR:
for (int i = 0; i < mSamples.size(); i++) {
final int sample = mSamples.get(i);
recorder.recordLinearHistogram(mName, sample, mMin, mMax, mNumBuckets);
}
break;
case Type.SPARSE:
for (int i = 0; i < mSamples.size(); i++) {
final int sample = mSamples.get(i);
recorder.recordSparseHistogram(mName, sample);
}
break;
default:
assert false : "Unknown histogram type " + mType;
}
int count = mSamples.size();
mSamples.clear();
return count;
}
}
/** Stores a single cached user action. */
private static class UserAction {
private final String mName;
private final long mElapsedRealtimeMillis;
UserAction(String name, long elapsedRealtimeMillis) {
mName = name;
mElapsedRealtimeMillis = elapsedRealtimeMillis;
}
/** Writes this user action to a {@link UmaRecorder}. */
void flushTo(UmaRecorder recorder) {
recorder.recordUserAction(mName, mElapsedRealtimeMillis);
}
}
/**
* The lock doesn't need to be fair - in the worst case a writing record*Histogram call will be
* starved until reading calls reach cache size limits.
* <p>
* A read-write lock is used rather than {@code synchronized} blocks to the limit opportunities
* for stutter on the UI thread when waiting for this shared resource.
*/
private final ReentrantReadWriteLock mRwLock = new ReentrantReadWriteLock(/*fair=*/false);
/** Cached histograms keyed by histogram name. */
@GuardedBy("mRwLock")
private Map<String, Histogram> mHistogramByName = new HashMap<>();
/**
* Number of histogram samples that couldn't be cached, because some limit of cache size been
* reached.
* <p>
* Using {@link AtomicInteger} because the value may need to be updated with a read lock held.
*/
private AtomicInteger mDroppedHistogramSampleCount = new AtomicInteger();
/** Cache of user actions. */
@GuardedBy("mRwLock")
private List<UserAction> mUserActions = new ArrayList<>();
/**
* Number of user actions that couldn't be cached, because the number of user actions in cache
* has reached its limit.
*/
@GuardedBy("mRwLock")
private int mDroppedUserActionCount;
/**
* If not {@code null}, all metrics are forwarded to this {@link UmaRecorder}.
* <p>
* The read lock must be held while invoking methods on {@code mDelegate}.
*/
@GuardedBy("mRwLock")
@Nullable
private UmaRecorder mDelegate;
@GuardedBy("mRwLock")
@Nullable
private List<Callback<String>> mUserActionCallbacksForTesting;
/**
* Sets the current delegate to {@code recorder}. Forwards and clears all cached metrics if
* {@code recorder} is not {@code null}.
*
* @param recorder new delegate.
* @return the previous delegate.
*/
public UmaRecorder setDelegate(@Nullable final UmaRecorder recorder) {
UmaRecorder previous;
Map<String, Histogram> histogramCache = null;
int droppedHistogramSampleCount = 0;
List<UserAction> userActionCache = null;
int droppedUserActionCount = 0;
mRwLock.writeLock().lock();
try {
previous = mDelegate;
mDelegate = recorder;
if (BuildConfig.IS_FOR_TEST) {
swapUserActionCallbacksForTesting(previous, recorder);
}
if (recorder == null) {
return previous;
}
if (!mHistogramByName.isEmpty()) {
histogramCache = mHistogramByName;
mHistogramByName = new HashMap<>();
droppedHistogramSampleCount = mDroppedHistogramSampleCount.getAndSet(0);
}
if (!mUserActions.isEmpty()) {
userActionCache = mUserActions;
mUserActions = new ArrayList<>();
droppedUserActionCount = mDroppedUserActionCount;
mDroppedUserActionCount = 0;
}
// Downgrade by acquiring read lock before releasing write lock
mRwLock.readLock().lock();
} finally {
mRwLock.writeLock().unlock();
}
// Cache is flushed only after downgrading from a write lock to a read lock.
try {
if (histogramCache != null) {
flushHistogramsAlreadyLocked(histogramCache, droppedHistogramSampleCount);
}
if (userActionCache != null) {
flushUserActionsAlreadyLocked(userActionCache, droppedUserActionCount);
}
} finally {
mRwLock.readLock().unlock();
}
return previous;
}
/**
* Writes histogram samples from {@code cache} to the delegate. Assumes that a read lock is held
* by the current thread.
*
* @param cache the cache to be flushed.
* @param droppedHistogramSampleCount number of histogram samples that were not recorded due to
* cache size limits.
*/
@GuardedBy("mRwLock")
private void flushHistogramsAlreadyLocked(
Map<String, Histogram> cache, int droppedHistogramSampleCount) {
assert mDelegate != null : "Unexpected: cache is flushed, but delegate is null";
assert mRwLock.getReadHoldCount() > 0;
int flushedHistogramSampleCount = 0;
final int flushedHistogramCount = cache.size();
for (Histogram histogram : cache.values()) {
flushedHistogramSampleCount += histogram.flushTo(mDelegate);
}
Log.i(TAG, "Flushed %d samples from %d histograms, %d samples were dropped.",
flushedHistogramSampleCount, flushedHistogramCount, droppedHistogramSampleCount);
}
/**
* Writes user actions from {@code cache} to the delegate. Assumes that a read lock is held by
* the current thread.
*
* @param cache the cache to be flushed.
* @param droppedUserActionCount number of user actions that were not recorded in {@code cache}
* to stay within {@link MAX_USER_ACTION_COUNT}.
*/
private void flushUserActionsAlreadyLocked(List<UserAction> cache, int droppedUserActionCount) {
assert mDelegate != null : "Unexpected: cache is flushed, but delegate is null";
assert mRwLock.getReadHoldCount() > 0;
for (UserAction userAction : cache) {
userAction.flushTo(mDelegate);
}
Log.i(TAG, "Flushed %d user action samples, %d samples were dropped.", cache.size(),
droppedUserActionCount);
}
/**
* Forwards or stores a histogram sample. Stores samples iff there is no delegate {@link
* UmaRecorder} set.
*
* @param type histogram type.
* @param name histogram name.
* @param sample sample value.
* @param min histogram min value.
* @param max histogram max value.
* @param numBuckets number of histogram buckets.
*/
private void cacheOrRecordHistogramSample(
@Histogram.Type int type, String name, int sample, int min, int max, int numBuckets) {
// Optimistic attempt without creating a Histogram.
if (tryAppendOrRecordSample(type, name, sample, min, max, numBuckets)) {
return;
}
mRwLock.writeLock().lock();
try {
if (mDelegate == null) {
cacheHistogramSampleAlreadyWriteLocked(type, name, sample, min, max, numBuckets);
return; // Skip the lock downgrade.
}
// Downgrade by acquiring read lock before releasing write lock
mRwLock.readLock().lock();
} finally {
mRwLock.writeLock().unlock();
}
// Downgraded to read lock.
// See base/android/java/src/org/chromium/base/metrics/forwarding_synchronization.md
try {
assert mDelegate != null;
recordHistogramSampleAlreadyLocked(type, name, sample, min, max, numBuckets);
} finally {
mRwLock.readLock().unlock();
}
}
/**
* Tries to cache or record a histogram sample without creating a new {@link Histogram}.
*
* @param type histogram type.
* @param name histogram name.
* @param sample sample value.
* @param min histogram min value.
* @param max histogram max value.
* @param numBuckets number of histogram buckets.
* @return {@code false} if the sample needs to be recorded with a write lock.
*/
private boolean tryAppendOrRecordSample(
@Histogram.Type int type, String name, int sample, int min, int max, int numBuckets) {
mRwLock.readLock().lock();
try {
if (mDelegate != null) {
recordHistogramSampleAlreadyLocked(type, name, sample, min, max, numBuckets);
return true;
}
Histogram histogram = mHistogramByName.get(name);
if (histogram == null) {
return false;
}
if (!histogram.addSample(type, name, sample, min, max, numBuckets)) {
mDroppedHistogramSampleCount.incrementAndGet();
}
return true;
} finally {
mRwLock.readLock().unlock();
}
}
/**
* Appends a histogram {@code sample} to a cached {@link Histogram}. Creates the {@code
* Histogram} if needed. Assumes that the <b>write lock</b> is held by the current thread.
*
* @param type histogram type.
* @param name histogram name.
* @param sample sample value.
* @param min histogram min value.
* @param max histogram max value.
* @param numBuckets number of histogram buckets.
*/
@GuardedBy("mRwLock")
private void cacheHistogramSampleAlreadyWriteLocked(
@Histogram.Type int type, String name, int sample, int min, int max, int numBuckets) {
assert mRwLock.isWriteLockedByCurrentThread();
Histogram histogram = mHistogramByName.get(name);
if (histogram == null) {
if (mHistogramByName.size() >= MAX_HISTOGRAM_COUNT) {
// A cache filling up is most likely an indication of a bug.
assert false : "Too many histograms in cache";
mDroppedHistogramSampleCount.incrementAndGet();
return;
}
histogram = new Histogram(type, name, min, max, numBuckets);
mHistogramByName.put(name, histogram);
}
if (!histogram.addSample(type, name, sample, min, max, numBuckets)) {
mDroppedHistogramSampleCount.incrementAndGet();
}
}
/**
* Forwards a histogram sample to the delegate. Assumes that a read lock is held by the current
* thread. Shouldn't be called with a write lock held.
*
* @param type histogram type.
* @param name histogram name.
* @param sample sample value.
* @param min histogram min value.
* @param max histogram max value.
* @param numBuckets number of histogram buckets.
*/
@GuardedBy("mRwLock")
private void recordHistogramSampleAlreadyLocked(
@Histogram.Type int type, String name, int sample, int min, int max, int numBuckets) {
assert mRwLock.getReadHoldCount() > 0;
assert !mRwLock.isWriteLockedByCurrentThread();
assert mDelegate != null : "recordSampleAlreadyLocked called with no delegate to record to";
switch (type) {
case Histogram.Type.BOOLEAN:
mDelegate.recordBooleanHistogram(name, sample != 0);
break;
case Histogram.Type.EXPONENTIAL:
mDelegate.recordExponentialHistogram(name, sample, min, max, numBuckets);
break;
case Histogram.Type.LINEAR:
mDelegate.recordLinearHistogram(name, sample, min, max, numBuckets);
break;
case Histogram.Type.SPARSE:
mDelegate.recordSparseHistogram(name, sample);
break;
default:
throw new UnsupportedOperationException("Unknown histogram type " + type);
}
}
@Override
public void recordBooleanHistogram(String name, boolean boolSample) {
final int sample = boolSample ? 1 : 0;
final int min = 0;
final int max = 0;
final int numBuckets = 0;
cacheOrRecordHistogramSample(Histogram.Type.BOOLEAN, name, sample, min, max, numBuckets);
}
@Override
public void recordExponentialHistogram(
String name, int sample, int min, int max, int numBuckets) {
cacheOrRecordHistogramSample(
Histogram.Type.EXPONENTIAL, name, sample, min, max, numBuckets);
}
@Override
public void recordLinearHistogram(String name, int sample, int min, int max, int numBuckets) {
cacheOrRecordHistogramSample(Histogram.Type.LINEAR, name, sample, min, max, numBuckets);
}
@Override
public void recordSparseHistogram(String name, int sample) {
final int min = 0;
final int max = 0;
final int numBuckets = 0;
cacheOrRecordHistogramSample(Histogram.Type.SPARSE, name, sample, min, max, numBuckets);
}
@Override
public void recordUserAction(String name, long elapsedRealtimeMillis) {
mRwLock.readLock().lock();
try {
if (mDelegate != null) {
mDelegate.recordUserAction(name, elapsedRealtimeMillis);
return;
}
} finally {
mRwLock.readLock().unlock();
}
mRwLock.writeLock().lock();
try {
if (mDelegate == null) {
if (mUserActions.size() < MAX_USER_ACTION_COUNT) {
mUserActions.add(new UserAction(name, elapsedRealtimeMillis));
} else {
assert false : "Too many user actions in cache";
mDroppedUserActionCount++;
}
if (mUserActionCallbacksForTesting != null) {
for (int i = 0; i < mUserActionCallbacksForTesting.size(); i++) {
mUserActionCallbacksForTesting.get(i).onResult(name);
}
}
return; // Skip the lock downgrade.
}
// Downgrade by acquiring read lock before releasing write lock
mRwLock.readLock().lock();
} finally {
mRwLock.writeLock().unlock();
}
// Downgraded to read lock.
// See base/android/java/src/org/chromium/base/metrics/forwarding_synchronization.md
try {
assert mDelegate != null;
mDelegate.recordUserAction(name, elapsedRealtimeMillis);
} finally {
mRwLock.readLock().unlock();
}
}
@VisibleForTesting
@Override
public int getHistogramValueCountForTesting(String name, int sample) {
mRwLock.readLock().lock();
try {
if (mDelegate != null) return mDelegate.getHistogramValueCountForTesting(name, sample);
Histogram histogram = mHistogramByName.get(name);
if (histogram == null) return 0;
int sampleCount = 0;
synchronized (histogram) {
for (int i = 0; i < histogram.mSamples.size(); i++) {
if (histogram.mSamples.get(i) == sample) sampleCount++;
}
}
return sampleCount;
} finally {
mRwLock.readLock().unlock();
}
}
@VisibleForTesting
@Override
public int getHistogramTotalCountForTesting(String name) {
mRwLock.readLock().lock();
try {
if (mDelegate != null) return mDelegate.getHistogramTotalCountForTesting(name);
Histogram histogram = mHistogramByName.get(name);
if (histogram == null) return 0;
synchronized (histogram) {
return histogram.mSamples.size();
}
} finally {
mRwLock.readLock().unlock();
}
}
@VisibleForTesting
@Override
public List<HistogramBucket> getHistogramSamplesForTesting(String name) {
mRwLock.readLock().lock();
try {
if (mDelegate != null) return mDelegate.getHistogramSamplesForTesting(name);
Histogram histogram = mHistogramByName.get(name);
if (histogram == null) return Collections.emptyList();
Integer[] samplesCopy;
synchronized (histogram) {
samplesCopy = histogram.mSamples.toArray(new Integer[0]);
}
Arrays.sort(samplesCopy);
List<HistogramBucket> buckets = new ArrayList<>();
for (int i = 0; i < samplesCopy.length;) {
int value = samplesCopy[i];
int countInBucket = 0;
do {
countInBucket++;
i++;
} while (i < samplesCopy.length && samplesCopy[i] == value);
buckets.add(new HistogramBucket(value, value + 1, countInBucket));
}
return buckets;
} finally {
mRwLock.readLock().unlock();
}
}
@VisibleForTesting
@Override
public void addUserActionCallbackForTesting(Callback<String> callback) {
mRwLock.writeLock().lock();
try {
if (mUserActionCallbacksForTesting == null) {
mUserActionCallbacksForTesting = new ArrayList<>();
}
mUserActionCallbacksForTesting.add(callback);
if (mDelegate != null) mDelegate.addUserActionCallbackForTesting(callback);
} finally {
mRwLock.writeLock().unlock();
}
}
@VisibleForTesting
@Override
public void removeUserActionCallbackForTesting(Callback<String> callback) {
mRwLock.writeLock().lock();
try {
if (mUserActionCallbacksForTesting == null) {
assert false : "Attempting to remove a user action callback without previously "
+ "registering any.";
return;
}
mUserActionCallbacksForTesting.remove(callback);
if (mDelegate != null) mDelegate.removeUserActionCallbackForTesting(callback);
} finally {
mRwLock.writeLock().unlock();
}
}
@SuppressLint("VisibleForTests")
@GuardedBy("mRwLock")
private void swapUserActionCallbacksForTesting(
@Nullable UmaRecorder previousRecorder, @Nullable UmaRecorder newRecorder) {
if (mUserActionCallbacksForTesting == null) return;
for (int i = 0; i < mUserActionCallbacksForTesting.size(); i++) {
if (previousRecorder != null) {
previousRecorder.removeUserActionCallbackForTesting(
mUserActionCallbacksForTesting.get(i));
}
if (newRecorder != null) {
newRecorder.addUserActionCallbackForTesting(mUserActionCallbacksForTesting.get(i));
}
}
}
}