chromium/android_webview/nonembedded/java/src/org/chromium/android_webview/services/MetricsBridgeService.java

// 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.android_webview.services;

import android.app.Service;
import android.content.Intent;
import android.os.Binder;
import android.os.IBinder;
import android.os.Process;

import androidx.annotation.IntDef;
import androidx.annotation.VisibleForTesting;

import com.google.protobuf.InvalidProtocolBufferException;

import org.chromium.android_webview.common.services.IMetricsBridgeService;
import org.chromium.android_webview.proto.MetricsBridgeRecords.HistogramRecord;
import org.chromium.android_webview.proto.MetricsBridgeRecords.HistogramRecord.RecordType;
import org.chromium.base.Log;
import org.chromium.base.PathUtils;
import org.chromium.base.task.PostTask;
import org.chromium.base.task.TaskRunner;
import org.chromium.base.task.TaskTraits;

import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.FutureTask;

/** Service that keeps record of UMA method calls in nonembedded WebView processes. */
public final class MetricsBridgeService extends Service {
    private static final String TAG = "MetricsBridgeService";

    // Max histograms this service will store, arbitrarily chosen
    private static final int MAX_HISTOGRAM_COUNT = 512;

    private static final String LOG_FILE_NAME = "webview_metrics_bridge_logs";

    private final File mLogFile;

    // Not guarded by a lock because it should only be accessed in a SequencedTaskRunner.
    private FileOutputStream mFileOutputStream;
    private List<byte[]> mRecordsList = new ArrayList<>();

    // To avoid any potential synchronization issues as well as avoid blocking the caller thread
    // (e.g when the caller is a thread from the same process.), we post all read/write operations
    // to be run serially using a SequencedTaskRunner instead of using a lock.
    private static final TaskRunner sSequencedTaskRunner =
            PostTask.createSequencedTaskRunner(TaskTraits.BEST_EFFORT_MAY_BLOCK);

    // These values are persisted to logs. Entries should not be renumbered and
    // numeric values should never be reused.
    @VisibleForTesting
    @IntDef({
        ParsingLogResult.SUCCESS,
        ParsingLogResult.MALFORMED_PROTOBUF,
        ParsingLogResult.IO_EXCEPTION
    })
    public @interface ParsingLogResult {
        int SUCCESS = 0;
        int MALFORMED_PROTOBUF = 1;
        int IO_EXCEPTION = 2;
        int COUNT = 3;
    }

    // Adding a histogram record to list and not calling base.metrics.RecordHistogram to avoid the
    // service calling itself.
    private void logParsingLogResult(@ParsingLogResult int sample) {
        // Similar to calling RecordHistogram.recordEnumeratedHistogram(
        //        "Android.WebView.NonEmbeddedMetrics.ParsingLogResult", sample,
        //        ParsingLogResult.COUNT);
        HistogramRecord record =
                HistogramRecord.newBuilder()
                        .setRecordType(RecordType.HISTOGRAM_LINEAR)
                        .setHistogramName("Android.WebView.NonEmbeddedMetrics.ParsingLogResult")
                        .setSample(sample)
                        .setMin(1)
                        .setMax(ParsingLogResult.COUNT)
                        .setNumBuckets(ParsingLogResult.COUNT + 1)
                        .build();
        // Add to the in-memory list but never written to file to avoid filling up the record list
        // and file with redundant records. However, this means when this record is sent to embedded
        // WebView it represents the parsing result for the most recent service start only.
        mRecordsList.add(record.toByteArray());
    }

    // These values are persisted to logs. Entries should not be renumbered and
    // numeric values should never be reused.
    @VisibleForTesting
    @IntDef({
        RetrieveMetricsTaskStatus.SUCCESS,
        RetrieveMetricsTaskStatus.EXECUTION_EXCEPTION,
        RetrieveMetricsTaskStatus.INTERRUPTED_EXCEPTION
    })
    public @interface RetrieveMetricsTaskStatus {
        int SUCCESS = 0;
        int EXECUTION_EXCEPTION = 1;
        int INTERRUPTED_EXCEPTION = 2;
        int COUNT = 3;
    }

    // Build a histogram record synchronously so it can be included in the batch of records sent to
    // the client instead of calling the base.metrics.RecordHistogram API (which is async and will
    // log in the next batch of records). This histogram captures errors that might happen when the
    // service is unable to send the current batch to the client. That's why this has to be added to
    // the current batch being sent.
    private static byte[] logRetrieveMetricsTaskStatus(@RetrieveMetricsTaskStatus int sample) {
        // Similar to calling RecordHistogram.recordEnumeratedHistogram(
        //        "Android.WebView.NonEmbeddedMetrics.RetrieveMetricsTaskStatus", sample,
        //        RetrieveMetricsTaskStatus.COUNT);
        HistogramRecord record =
                HistogramRecord.newBuilder()
                        .setRecordType(RecordType.HISTOGRAM_LINEAR)
                        .setHistogramName(
                                "Android.WebView.NonEmbeddedMetrics.RetrieveMetricsTaskStatus")
                        .setSample(sample)
                        .setMin(1)
                        .setMax(RetrieveMetricsTaskStatus.COUNT)
                        .setNumBuckets(ParsingLogResult.COUNT + 1)
                        .build();
        return record.toByteArray();
    }

    @Override
    public void onCreate() {
        // Restore saved histograms from disk.
        sSequencedTaskRunner.postTask(
                () -> {
                    File file = getMetricsLogFile();
                    if (!file.exists()) return;
                    try (FileInputStream in = new FileInputStream(file)) {
                        HistogramRecord proto;
                        while ((proto = HistogramRecord.parseDelimitedFrom(in)) != null) {
                            // The proto message object isn't needed anymore, we will store its byte
                            // serialization.
                            mRecordsList.add(proto.toByteArray());
                        }
                        logParsingLogResult(ParsingLogResult.SUCCESS);
                    } catch (InvalidProtocolBufferException | IllegalStateException e) {
                        Log.e(TAG, "Malformed metrics log proto", e);
                        logParsingLogResult(ParsingLogResult.MALFORMED_PROTOBUF);
                        deleteMetricsLogFile();
                    } catch (IOException e) {
                        logParsingLogResult(ParsingLogResult.IO_EXCEPTION);
                        Log.e(TAG, "Failed reading proto log file", e);
                    }
                });
    }

    public MetricsBridgeService() {
        this(new File(PathUtils.getDataDirectory(), LOG_FILE_NAME));
    }

    @VisibleForTesting
    // Inject a logFile for testing.
    public MetricsBridgeService(File logFile) {
        mLogFile = logFile;
    }

    private final IMetricsBridgeService.Stub mBinder =
            new IMetricsBridgeService.Stub() {
                @Override
                public void recordMetrics(byte[] data) {
                    if (Binder.getCallingUid() != Process.myUid()) {
                        throw new SecurityException(
                                "recordMetrics() may only be called by non-embedded WebView"
                                        + " processes");
                    }
                    // If this is called within the same process, it will run on the caller thread,
                    // so we will always punt this to thread pool.
                    sSequencedTaskRunner.postTask(
                            () -> {
                                // Make sure that we don't add records indefinitely in case of no
                                // embedded WebView connects to the service to retrieve and clear
                                // the records.
                                if (mRecordsList.size() >= MAX_HISTOGRAM_COUNT) {
                                    // TODO(crbug.com/40695441) add a histogram to log the
                                    // number of dropped histograms.
                                    Log.w(
                                            TAG,
                                            "retained records has reached the max capacity,"
                                                    + " dropping record");
                                    return;
                                }
                                try {
                                    // Parse data to make sure it's valid HistogramRecord byte data.
                                    HistogramRecord proto = HistogramRecord.parseFrom(data);
                                    mRecordsList.add(data);
                                    // Append the histogram record to log file.
                                    FileOutputStream out = getMetricsLogOutputStream();
                                    proto.writeDelimitedTo(out);
                                    // Flush the stream to make sure the bytes are written to file
                                    // in cases when the service isn't closed gracefully.
                                    out.flush();
                                } catch (InvalidProtocolBufferException e) {
                                    Log.e(TAG, "Malformed metrics log proto", e);
                                } catch (IOException e) {
                                    Log.e(TAG, "Failed to write to file", e);
                                }
                            });
                }

                @Override
                public List<byte[]> retrieveNonembeddedMetrics() {
                    FutureTask<List<byte[]>> retrieveFutureTask =
                            new FutureTask<>(
                                    () -> {
                                        List<byte[]> list = mRecordsList;
                                        mRecordsList = new ArrayList<>();
                                        deleteMetricsLogFile();
                                        return list;
                                    });
                    sSequencedTaskRunner.postTask(retrieveFutureTask);
                    try {
                        return retrieveFutureTask.get();
                    } catch (ExecutionException e) {
                        Log.e(TAG, "error executing retrieveNonembeddedMetrics future task", e);
                    } catch (InterruptedException e) {
                        Log.e(TAG, "retrieveNonembeddedMetrics future task interrupted", e);
                    }
                    return new ArrayList<>();
                }
            };

    @Override
    public IBinder onBind(Intent intent) {
        return mBinder;
    }

    private File getMetricsLogFile() {
        return mLogFile;
    }

    private FileOutputStream getMetricsLogOutputStream() throws IOException {
        if (mFileOutputStream == null) {
            mFileOutputStream = new FileOutputStream(getMetricsLogFile(), /* append= */ true);
        }
        return mFileOutputStream;
    }

    private void closeMetricsLogOutputStream() {
        try {
            if (mFileOutputStream != null) {
                mFileOutputStream.close();
            }
        } catch (IOException e) {
            Log.e(TAG, "Couldn't close file output stream", e);
        } finally {
            mFileOutputStream = null;
        }
    }

    private boolean deleteMetricsLogFile() {
        closeMetricsLogOutputStream();
        return getMetricsLogFile().delete();
    }

    @Override
    public void onDestroy() {
        closeMetricsLogOutputStream();
    }

    /**
     * Add a FutureTask that can be used to block until all the tasks in the local
     * {@code sSequencedTaskRunner} are finished for testing.
     */
    @VisibleForTesting
    public FutureTask addTaskToBlock() {
        FutureTask<Object> blockTask = new FutureTask<Object>(() -> {}, new Object());
        sSequencedTaskRunner.postTask(blockTask);
        return blockTask;
    }
}