chromium/components/cronet/android/java/src/org/chromium/net/telemetry/CronetLoggerImpl.java

// Copyright 2023 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.net.telemetry;

import android.os.Build;
import android.util.Log;

import androidx.annotation.RequiresApi;
import androidx.annotation.VisibleForTesting;

import org.chromium.net.impl.CronetLogger;

import java.util.List;
import java.util.concurrent.ThreadLocalRandom;
import java.util.concurrent.atomic.AtomicInteger;

/** Logger for logging cronet's telemetry */
@RequiresApi(Build.VERSION_CODES.R)
public class CronetLoggerImpl extends CronetLogger {
    private static final String TAG = CronetLoggerImpl.class.getSimpleName();

    private final AtomicInteger mSamplesRateLimited = new AtomicInteger();
    private final RateLimiter mRateLimiter;

    public CronetLoggerImpl(int sampleRatePerSecond) {
        this(new RateLimiter(sampleRatePerSecond));
    }

    @VisibleForTesting
    public CronetLoggerImpl(RateLimiter rateLimiter) {
        super();
        this.mRateLimiter = rateLimiter;
    }

    @Override
    public long generateId() {
        // Pick an ID at random, but avoid Long.MIN_VALUE, Long.MAX_VALUE, 0 and -1, as these may
        // be confused with values people may think of as sentinels.
        long id = ThreadLocalRandom.current().nextLong(Long.MIN_VALUE + 1, Long.MAX_VALUE - 2);
        return id >= -1 ? id + 2 : id;
    }

    @Override
    public void logCronetEngineBuilderInitializedInfo(CronetEngineBuilderInitializedInfo info) {
        CronetStatsLog.write(
                CronetStatsLog.CRONET_ENGINE_BUILDER_INITIALIZED,
                info.cronetInitializationRef,
                convertToProtoCronetEngineBuilderInitializedAuthor(info.author),
                info.engineBuilderCreatedLatencyMillis,
                convertToProtoCronetEngineBuilderInitializedSource(info.source),
                OptionalBoolean.fromBoolean(info.creationSuccessful).getValue(),
                info.apiVersion.getMajorVersion(),
                info.apiVersion.getMinorVersion(),
                info.apiVersion.getBuildVersion(),
                info.apiVersion.getPatchVersion(),
                // These null checks actually matter. See b/329601514.
                info.implVersion == null ? -1 : info.implVersion.getMajorVersion(),
                info.implVersion == null ? -1 : info.implVersion.getMinorVersion(),
                info.implVersion == null ? -1 : info.implVersion.getBuildVersion(),
                info.implVersion == null ? -1 : info.implVersion.getPatchVersion(),
                info.uid);
    }

    @Override
    public void logCronetInitializedInfo(CronetInitializedInfo info) {
        // This atom uses arrays, which are only supported by StatsLog starting from Android T. If
        // we are running Android <T we simply drop the atom, which is fine-ish because it doesn't
        // carry critical information, nor does it carry information that other atoms may want to
        // join against.
        if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return;

        CronetStatsLog.write(
                CronetStatsLog.CRONET_INITIALIZED,
                info.cronetInitializationRef,
                info.engineCreationLatencyMillis,
                info.engineAsyncLatencyMillis,
                info.httpFlagsLatencyMillis,
                OptionalBoolean.fromBoolean(info.httpFlagsSuccessful).getValue(),
                longListToLongArray(info.httpFlagsNames),
                longListToLongArray(info.httpFlagsValues));
    }

    @Override
    public void logCronetEngineCreation(
            long cronetEngineId,
            CronetEngineBuilderInfo builder,
            CronetVersion version,
            CronetSource source) {
        if (builder == null || version == null || source == null) {
            return;
        }

        writeCronetEngineCreation(cronetEngineId, builder, version, source);
    }

    @Override
    public void logCronetTrafficInfo(long cronetEngineId, CronetTrafficInfo trafficInfo) {
        if (trafficInfo == null) {
            return;
        }

        if (!mRateLimiter.tryAcquire()) {
            mSamplesRateLimited.incrementAndGet();
            return;
        }

        writeCronetTrafficReported(cronetEngineId, trafficInfo, mSamplesRateLimited.getAndSet(0));
    }

    @SuppressWarnings("CatchingUnchecked")
    public void writeCronetEngineCreation(
            long cronetEngineId,
            CronetEngineBuilderInfo builder,
            CronetVersion version,
            CronetSource source) {
        try {
            // Parse experimental Options
            ExperimentalOptions experimentalOptions =
                    new ExperimentalOptions(builder.getExperimentalOptions());

            CronetStatsLog.write(
                    CronetStatsLog.CRONET_ENGINE_CREATED,
                    cronetEngineId,
                    version.getMajorVersion(),
                    version.getMinorVersion(),
                    version.getBuildVersion(),
                    version.getPatchVersion(),
                    convertToProtoCronetEngineCreatedSource(source),
                    builder.isBrotliEnabled(),
                    builder.isHttp2Enabled(),
                    convertToProtoHttpCacheMode(builder.getHttpCacheMode()),
                    builder.isPublicKeyPinningBypassForLocalTrustAnchorsEnabled(),
                    builder.isQuicEnabled(),
                    builder.isNetworkQualityEstimatorEnabled(),
                    builder.getThreadPriority(),
                    // QUIC options
                    experimentalOptions.getConnectionOptionsOption(),
                    experimentalOptions.getStoreServerConfigsInPropertiesOption().getValue(),
                    experimentalOptions.getMaxServerConfigsStoredInPropertiesOption(),
                    experimentalOptions.getIdleConnectionTimeoutSecondsOption(),
                    experimentalOptions.getGoawaySessionsOnIpChangeOption().getValue(),
                    experimentalOptions.getCloseSessionsOnIpChangeOption().getValue(),
                    experimentalOptions.getMigrateSessionsOnNetworkChangeV2Option().getValue(),
                    experimentalOptions.getMigrateSessionsEarlyV2().getValue(),
                    experimentalOptions.getDisableBidirectionalStreamsOption().getValue(),
                    experimentalOptions.getMaxTimeBeforeCryptoHandshakeSecondsOption(),
                    experimentalOptions.getMaxIdleTimeBeforeCryptoHandshakeSecondsOption(),
                    experimentalOptions.getEnableSocketRecvOptimizationOption().getValue(),
                    // AsyncDNS
                    experimentalOptions.getAsyncDnsEnableOption().getValue(),
                    // StaleDNS
                    experimentalOptions.getStaleDnsEnableOption().getValue(),
                    experimentalOptions.getStaleDnsDelayMillisOption(),
                    experimentalOptions.getStaleDnsMaxExpiredTimeMillisOption(),
                    experimentalOptions.getStaleDnsMaxStaleUsesOption(),
                    experimentalOptions.getStaleDnsAllowOtherNetworkOption().getValue(),
                    experimentalOptions.getStaleDnsPersistToDiskOption().getValue(),
                    experimentalOptions.getStaleDnsPersistDelayMillisOption(),
                    experimentalOptions.getStaleDnsUseStaleOnNameNotResolvedOption().getValue(),
                    experimentalOptions.getDisableIpv6OnWifiOption().getValue(),
                    builder.getCronetInitializationRef());
        } catch (Exception e) { // catching all exceptions since we don't want to crash the client
            if (Log.isLoggable(TAG, Log.DEBUG)) {
                Log.d(
                        TAG,
                        String.format(
                                "Failed to log CronetEngine:%s creation: %s",
                                cronetEngineId, e.getMessage()));
            }
        }
    }

    @SuppressWarnings("CatchingUnchecked")
    @VisibleForTesting
    public void writeCronetTrafficReported(
            long cronetEngineId, CronetTrafficInfo trafficInfo, int samplesRateLimitedCount) {
        try {
            CronetStatsLog.write(
                    CronetStatsLog.CRONET_TRAFFIC_REPORTED,
                    cronetEngineId,
                    SizeBuckets.calcRequestHeadersSizeBucket(
                            trafficInfo.getRequestHeaderSizeInBytes()),
                    SizeBuckets.calcRequestBodySizeBucket(trafficInfo.getRequestBodySizeInBytes()),
                    SizeBuckets.calcResponseHeadersSizeBucket(
                            trafficInfo.getResponseHeaderSizeInBytes()),
                    SizeBuckets.calcResponseBodySizeBucket(
                            trafficInfo.getResponseBodySizeInBytes()),
                    trafficInfo.getResponseStatusCode(),
                    Hash.hash(trafficInfo.getNegotiatedProtocol()),
                    (int) trafficInfo.getHeadersLatency().toMillis(),
                    (int) trafficInfo.getTotalLatency().toMillis(),
                    trafficInfo.wasConnectionMigrationAttempted(),
                    trafficInfo.didConnectionMigrationSucceed(),
                    samplesRateLimitedCount,
                    convertToProtoCronetRequestTerminalState(trafficInfo.getTerminalState()),
                    trafficInfo.getNonfinalUserCallbackExceptionCount(),
                    /* total_idle_time_millis= */ -1,
                    /* total_user_executor_execute_latency_millis= */ -1,
                    trafficInfo.getReadCount(),
                    trafficInfo.getOnUploadReadCount(),
                    OptionalBoolean.fromBoolean(trafficInfo.getIsBidiStream()).getValue(),
                    OptionalBoolean.fromBoolean(trafficInfo.getFinalUserCallbackThrew())
                            .getValue());
        } catch (Exception e) {
            // using addAndGet because another thread might have modified samplesRateLimited's value
            mSamplesRateLimited.addAndGet(samplesRateLimitedCount);
            if (Log.isLoggable(TAG, Log.DEBUG)) {
                Log.d(
                        TAG,
                        String.format(
                                "Failed to log cronet traffic sample for CronetEngine %s: %s",
                                cronetEngineId, e.getMessage()));
            }
        }
    }

    private static int convertToProtoCronetEngineBuilderInitializedAuthor(
            CronetEngineBuilderInitializedInfo.Author author) {
        switch (author) {
            case API:
                return CronetStatsLog.CRONET_ENGINE_BUILDER_INITIALIZED__AUTHOR__AUTHOR_API;
            case IMPL:
                return CronetStatsLog.CRONET_ENGINE_BUILDER_INITIALIZED__AUTHOR__AUTHOR_IMPL;
        }
        return CronetStatsLog.CRONET_ENGINE_BUILDER_INITIALIZED__AUTHOR__AUTHOR_UNSPECIFIED;
    }

    private static int convertToProtoCronetRequestTerminalState(
            CronetTrafficInfo.RequestTerminalState requestTerminalState) {
        switch (requestTerminalState) {
            case SUCCEEDED:
                return CronetStatsLog.CRONET_TRAFFIC_REPORTED__TERMINAL_STATE__STATE_SUCCEEDED;
            case ERROR:
                return CronetStatsLog.CRONET_TRAFFIC_REPORTED__TERMINAL_STATE__STATE_ERROR;
            case CANCELLED:
                return CronetStatsLog.CRONET_TRAFFIC_REPORTED__TERMINAL_STATE__STATE_CANCELLED;
            default:
                return CronetStatsLog.CRONET_TRAFFIC_REPORTED__TERMINAL_STATE__STATE_UNKNOWN;
        }
    }

    private static int convertToProtoCronetEngineBuilderInitializedSource(CronetSource source) {
        switch (source) {
            case CRONET_SOURCE_STATICALLY_LINKED:
                return CronetStatsLog
                        .CRONET_ENGINE_BUILDER_INITIALIZED__SOURCE__CRONET_SOURCE_EMBEDDED_NATIVE;
            case CRONET_SOURCE_PLAY_SERVICES:
                return CronetStatsLog
                        .CRONET_ENGINE_BUILDER_INITIALIZED__SOURCE__CRONET_SOURCE_GMSCORE_NATIVE;
            case CRONET_SOURCE_FALLBACK:
                return CronetStatsLog
                        .CRONET_ENGINE_BUILDER_INITIALIZED__SOURCE__CRONET_SOURCE_EMBEDDED_JAVA;
            case CRONET_SOURCE_PLATFORM:
                return CronetStatsLog
                        .CRONET_ENGINE_BUILDER_INITIALIZED__SOURCE__CRONET_SOURCE_HTTPENGINE_NATIVE;
            default:
                return CronetStatsLog
                        .CRONET_ENGINE_BUILDER_INITIALIZED__SOURCE__CRONET_SOURCE_UNSPECIFIED;
        }
    }

    private static int convertToProtoCronetEngineCreatedSource(CronetSource source) {
        switch (source) {
            case CRONET_SOURCE_STATICALLY_LINKED:
                return CronetStatsLog
                        .CRONET_ENGINE_CREATED__SOURCE__CRONET_SOURCE_STATICALLY_LINKED;
            case CRONET_SOURCE_PLAY_SERVICES:
                return CronetStatsLog.CRONET_ENGINE_CREATED__SOURCE__CRONET_SOURCE_GMSCORE_DYNAMITE;
            case CRONET_SOURCE_FALLBACK:
                return CronetStatsLog.CRONET_ENGINE_CREATED__SOURCE__CRONET_SOURCE_FALLBACK;
            case CRONET_SOURCE_UNSPECIFIED:
                return CronetStatsLog.CRONET_ENGINE_CREATED__SOURCE__CRONET_SOURCE_UNSPECIFIED;
            default:
                return CronetStatsLog.CRONET_ENGINE_CREATED__SOURCE__CRONET_SOURCE_UNSPECIFIED;
        }
    }

    private static int convertToProtoHttpCacheMode(int httpCacheMode) {
        switch (httpCacheMode) {
            case 0:
                return CronetStatsLog.CRONET_ENGINE_CREATED__HTTP_CACHE_MODE__HTTP_CACHE_DISABLED;
            case 1:
                return CronetStatsLog.CRONET_ENGINE_CREATED__HTTP_CACHE_MODE__HTTP_CACHE_DISK;
            case 2:
                return CronetStatsLog
                        .CRONET_ENGINE_CREATED__HTTP_CACHE_MODE__HTTP_CACHE_DISK_NO_HTTP;
            case 3:
                return CronetStatsLog.CRONET_ENGINE_CREATED__HTTP_CACHE_MODE__HTTP_CACHE_IN_MEMORY;
            default:
                throw new IllegalArgumentException("Expected httpCacheMode to range from 0 to 3");
        }
    }

    // Shamelessly copy-pasted from //base/android/java/src/org/chromium/base/CollectionUtil.java
    // to avoid adding a large dependency on //base.
    private static long[] longListToLongArray(List<Long> list) {
        long[] array = new long[list.size()];
        for (int i = 0; i < list.size(); i++) {
            array[i] = list.get(i);
        }
        return array;
    }
}