diff options
Diffstat (limited to 'mobile/android/android-components/components/service/glean')
17 files changed, 1029 insertions, 0 deletions
diff --git a/mobile/android/android-components/components/service/glean/README.md b/mobile/android/android-components/components/service/glean/README.md new file mode 100644 index 0000000000..c08c0d17f1 --- /dev/null +++ b/mobile/android/android-components/components/service/glean/README.md @@ -0,0 +1,21 @@ +# [Android Components](../../../README.md) > Service > Glean + +A client-side telemetry SDK for collecting metrics and sending them to Mozilla's telemetry service. + +Visit the [complete Glean SDK documentation](https://mozilla.github.io/glean/). + +## Contact + +To contact us you can: +- Find us in the [#glean channel on chat.mozilla.org](https://chat.mozilla.org/#/room/#glean:mozilla.org). +* To report issues or request changes, file a bug in [Bugzilla in Data Platform & Tools :: Glean: SDK][newbugzilla]. +- Send an email to *glean-team@mozilla.com*. +* The Glean Android team is: *:dexter*, *:travis*, *:mdroettboom*, *:janerik*, *:brizental*. + +## License + + This Source Code Form is subject to the terms of the Mozilla Public + License, v. 2.0. If a copy of the MPL was not distributed with this + file, You can obtain one at http://mozilla.org/MPL/2.0/ + +[newbugzilla]: https://bugzilla.mozilla.org/enter_bug.cgi?product=Data+Platform+and+Tools&component=Glean%3A+SDK&priority=P3&status_whiteboard=%5Btelemetry%3Aglean-rs%3Am%3F%5D diff --git a/mobile/android/android-components/components/service/glean/build.gradle b/mobile/android/android-components/components/service/glean/build.gradle new file mode 100644 index 0000000000..aaed4bac50 --- /dev/null +++ b/mobile/android/android-components/components/service/glean/build.gradle @@ -0,0 +1,58 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +apply plugin: 'com.android.library' +apply plugin: 'kotlin-android' + +android { + defaultConfig { + minSdkVersion config.minSdkVersion + compileSdk config.compileSdkVersion + targetSdkVersion config.targetSdkVersion + } + + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' + } + } + + namespace 'mozilla.components.service.glean' +} + +// Define library names and version constants. +String GLEAN_LIBRARY = "org.mozilla.telemetry:glean:${Versions.mozilla_glean}" +String GLEAN_LIBRARY_FORUNITTESTS = "org.mozilla.telemetry:glean-native-forUnitTests:${Versions.mozilla_glean}" + +dependencies { + implementation ComponentsDependencies.kotlin_coroutines + implementation ComponentsDependencies.androidx_work_runtime + + api GLEAN_LIBRARY + + // So consumers can set a HTTP client. + api project(':concept-fetch') + + implementation project(':support-ktx') + implementation project(':support-base') + implementation project(':support-utils') + + testImplementation ComponentsDependencies.androidx_test_core + + testImplementation ComponentsDependencies.testing_junit + testImplementation ComponentsDependencies.testing_robolectric + testImplementation ComponentsDependencies.testing_mockwebserver + testImplementation ComponentsDependencies.androidx_work_testing + + testImplementation project(':support-test') + testImplementation project(':lib-fetch-httpurlconnection') + testImplementation project(':lib-fetch-okhttp') + + testImplementation GLEAN_LIBRARY_FORUNITTESTS +} + +apply from: '../../../android-lint.gradle' +apply from: '../../../publish.gradle' +ext.configurePublish(config.componentsGroupId, archivesBaseName, project.ext.description) diff --git a/mobile/android/android-components/components/service/glean/gradle.properties b/mobile/android/android-components/components/service/glean/gradle.properties new file mode 100644 index 0000000000..e69de29bb2 --- /dev/null +++ b/mobile/android/android-components/components/service/glean/gradle.properties diff --git a/mobile/android/android-components/components/service/glean/proguard-rules.pro b/mobile/android/android-components/components/service/glean/proguard-rules.pro new file mode 100644 index 0000000000..f1b424510d --- /dev/null +++ b/mobile/android/android-components/components/service/glean/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile diff --git a/mobile/android/android-components/components/service/glean/src/main/AndroidManifest.xml b/mobile/android/android-components/components/service/glean/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..e16cda1d34 --- /dev/null +++ b/mobile/android/android-components/components/service/glean/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ +<!-- This Source Code Form is subject to the terms of the Mozilla Public + - License, v. 2.0. If a copy of the MPL was not distributed with this + - file, You can obtain one at http://mozilla.org/MPL/2.0/. --> +<manifest /> diff --git a/mobile/android/android-components/components/service/glean/src/main/java/mozilla/components/service/glean/Glean.kt b/mobile/android/android-components/components/service/glean/src/main/java/mozilla/components/service/glean/Glean.kt new file mode 100644 index 0000000000..232ac1b83f --- /dev/null +++ b/mobile/android/android-components/components/service/glean/src/main/java/mozilla/components/service/glean/Glean.kt @@ -0,0 +1,145 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.service.glean + +import android.content.Context +import androidx.annotation.MainThread +import androidx.annotation.VisibleForTesting +import mozilla.components.service.glean.config.Configuration +import mozilla.components.service.glean.private.RecordedExperiment +import org.json.JSONObject +import mozilla.telemetry.glean.Glean as GleanCore + +typealias BuildInfo = mozilla.telemetry.glean.BuildInfo + +/** + * In contrast with other glean-ac classes (i.e. Configuration), we can't + * use typealias to export mozilla.telemetry.glean.Glean, as we need to provide + * a different default [Configuration]. Moreover, we can't simply delegate other + * methods or inherit, since that doesn't work for `object` in Kotlin. + */ +object Glean { + /** + * Initialize Glean. + * + * This should only be initialized once by the application, and not by + * libraries using Glean. A message is logged to error and no changes are made + * to the state if initialize is called a more than once. + * + * A LifecycleObserver will be added to send pings when the application goes + * into the background. + * + * @param applicationContext [Context] to access application features, such + * as shared preferences + * @param uploadEnabled A [Boolean] that determines the initial state of the uploader + * @param configuration A Glean [Configuration] object with global settings. + * @param buildInfo A Glean [BuildInfo] object with build-time metadata. This + * object is generated at build time by glean_parser at the import path + * ${YOUR_PACKAGE_ROOT}.GleanMetrics.GleanBuildInfo.buildInfo + */ + @MainThread + fun initialize( + applicationContext: Context, + uploadEnabled: Boolean, + configuration: Configuration, + buildInfo: BuildInfo, + ) { + GleanCore.initialize( + applicationContext = applicationContext, + uploadEnabled = uploadEnabled, + configuration = configuration.toWrappedConfiguration(), + buildInfo = buildInfo, + ) + } + + /** + * Register the pings generated from `pings.yaml` with Glean. + * + * @param pings The `Pings` object generated for your library or application + * by Glean. + */ + fun registerPings(pings: Any) { + GleanCore.registerPings(pings) + } + + /** + * Enable or disable Glean collection and upload. + * + * Metric collection is enabled by default. + * + * When disabled, metrics aren't recorded at all and no data + * is uploaded. + * + * @param enabled When true, enable metric collection. + */ + fun setUploadEnabled(enabled: Boolean) { + GleanCore.setUploadEnabled(enabled) + } + + /** + * Indicate that an experiment is running. Glean will then add an + * experiment annotation to the environment which is sent with pings. This + * information is not persisted between runs. + * + * @param experimentId The id of the active experiment (maximum + * 30 bytes) + * @param branch The experiment branch (maximum 30 bytes) + * @param extra Optional metadata to output with the ping + */ + @JvmOverloads + fun setExperimentActive( + experimentId: String, + branch: String, + extra: Map<String, String>? = null, + ) { + GleanCore.setExperimentActive( + experimentId = experimentId, + branch = branch, + extra = extra, + ) + } + + /** + * Indicate that an experiment is no longer running. + * + * @param experimentId The id of the experiment to deactivate. + */ + fun setExperimentInactive(experimentId: String) { + GleanCore.setExperimentInactive(experimentId = experimentId) + } + + /** + * Set configuration to override metrics' enabled state, typically from a remote_settings + * experiment or rollout. + * + * @param enabled Map of metrics' enabled state. + */ + fun setMetricsEnabledConfig(enabled: Map<String, Boolean>) { + GleanCore.setMetricsEnabledConfig(JSONObject(enabled).toString()) + } + + /** + * Tests whether an experiment is active, for testing purposes only. + * + * @param experimentId the id of the experiment to look for. + * @return true if the experiment is active and reported in pings, otherwise false + */ + @VisibleForTesting(otherwise = VisibleForTesting.NONE) + fun testIsExperimentActive(experimentId: String): Boolean { + return GleanCore.testIsExperimentActive(experimentId) + } + + /** + * Returns the stored data for the requested active experiment, for testing purposes only. + * + * @param experimentId the id of the experiment to look for. + * @return the [RecordedExperiment] for the experiment + * @throws [NullPointerException] if the requested experiment is not active or data is corrupt. + */ + @VisibleForTesting(otherwise = VisibleForTesting.NONE) + fun testGetExperimentData(experimentId: String): RecordedExperiment { + return GleanCore.testGetExperimentData(experimentId) + } +} diff --git a/mobile/android/android-components/components/service/glean/src/main/java/mozilla/components/service/glean/config/Configuration.kt b/mobile/android/android-components/components/service/glean/src/main/java/mozilla/components/service/glean/config/Configuration.kt new file mode 100644 index 0000000000..11911c7046 --- /dev/null +++ b/mobile/android/android-components/components/service/glean/src/main/java/mozilla/components/service/glean/config/Configuration.kt @@ -0,0 +1,50 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.service.glean.config + +import mozilla.telemetry.glean.net.PingUploader +import mozilla.telemetry.glean.config.Configuration as GleanCoreConfiguration + +/** + * The Configuration class describes how to configure the Glean. + * + * @property httpClient The HTTP client implementation to use for uploading pings. + * If you don't provide your own networking stack with an HTTP client to use, + * you can fall back to a simple implementation on top of `java.net` using + * `ConceptFetchHttpUploader(lazy { HttpURLConnectionClient() as Client })` + * @property serverEndpoint (optional) the server pings are sent to. Please note that this is + * is only meant to be changed for tests. + * @property channel (optional )the release channel the application is on, if known. This will be + * sent along with all the pings, in the `client_info` section. + * @property maxEvents (optional) the number of events to store before the events ping is sent + */ +data class Configuration @JvmOverloads constructor( + val httpClient: PingUploader, + val serverEndpoint: String = DEFAULT_TELEMETRY_ENDPOINT, + val channel: String? = null, + val maxEvents: Int? = null, + val enableEventTimestamps: Boolean = false, +) { + // The following is required to support calling our API from Java. + companion object { + const val DEFAULT_TELEMETRY_ENDPOINT = GleanCoreConfiguration.DEFAULT_TELEMETRY_ENDPOINT + } + + /** + * Convert the Android Components configuration object to the Glean SDK + * configuration object. + * + * @return a [mozilla.telemetry.glean.config.Configuration] instance. + */ + fun toWrappedConfiguration(): GleanCoreConfiguration { + return GleanCoreConfiguration( + serverEndpoint = serverEndpoint, + channel = channel, + maxEvents = maxEvents, + httpClient = httpClient, + enableEventTimestamps = enableEventTimestamps, + ) + } +} diff --git a/mobile/android/android-components/components/service/glean/src/main/java/mozilla/components/service/glean/net/ConceptFetchHttpUploader.kt b/mobile/android/android-components/components/service/glean/src/main/java/mozilla/components/service/glean/net/ConceptFetchHttpUploader.kt new file mode 100644 index 0000000000..06947e6d5b --- /dev/null +++ b/mobile/android/android-components/components/service/glean/src/main/java/mozilla/components/service/glean/net/ConceptFetchHttpUploader.kt @@ -0,0 +1,109 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.service.glean.net + +import androidx.annotation.VisibleForTesting +import androidx.annotation.VisibleForTesting.Companion.PRIVATE +import mozilla.components.concept.fetch.Client +import mozilla.components.concept.fetch.Header +import mozilla.components.concept.fetch.Request +import mozilla.components.concept.fetch.toMutableHeaders +import mozilla.components.support.base.log.logger.Logger +import mozilla.telemetry.glean.net.HeadersList +import mozilla.telemetry.glean.net.HttpStatus +import mozilla.telemetry.glean.net.RecoverableFailure +import mozilla.telemetry.glean.net.UploadResult +import java.io.IOException +import java.util.concurrent.TimeUnit +import mozilla.telemetry.glean.net.PingUploader as CorePingUploader + +typealias PingUploader = CorePingUploader + +/** + * A simple ping Uploader, which implements a "send once" policy, never + * storing or attempting to send the ping again. This uses Android Component's + * `concept-fetch`. + * + * @param usePrivateRequest Sets the [Request.private] flag in all requests using this uploader. + */ +class ConceptFetchHttpUploader( + internal val client: Lazy<Client>, + private val usePrivateRequest: Boolean = false, +) : PingUploader { + private val logger = Logger("glean/ConceptFetchHttpUploader") + + companion object { + // The timeout, in milliseconds, to use when connecting to the server. + const val DEFAULT_CONNECTION_TIMEOUT = 10000L + + // The timeout, in milliseconds, to use when reading from the server. + const val DEFAULT_READ_TIMEOUT = 30000L + + /** + * Export a constructor that is usable from Java. + * + * This looses the lazyness of creating the `client`. + */ + @JvmStatic + fun fromClient(client: Client): ConceptFetchHttpUploader { + return ConceptFetchHttpUploader(lazy { client }) + } + } + + /** + * Synchronously upload a ping to a server. + * + * @param url the URL path to upload the data to + * @param data the serialized text data to send + * @param headers a [HeadersList] containing String to String [Pair] with + * the first entry being the header name and the second its value. + * + * @return true if the ping was correctly dealt with (sent successfully + * or faced an unrecoverable error), false if there was a recoverable + * error callers can deal with. + */ + override fun upload(url: String, data: ByteArray, headers: HeadersList): UploadResult { + val request = buildRequest(url, data, headers) + + return try { + performUpload(client.value, request) + } catch (e: IOException) { + logger.warn("IOException while uploading ping", e) + RecoverableFailure(0) + } + } + + @VisibleForTesting(otherwise = PRIVATE) + internal fun buildRequest( + url: String, + data: ByteArray, + headers: HeadersList, + ): Request { + val conceptHeaders = headers.map { (name, value) -> Header(name, value) }.toMutableHeaders() + + return Request( + url = url, + method = Request.Method.POST, + connectTimeout = Pair(DEFAULT_CONNECTION_TIMEOUT, TimeUnit.MILLISECONDS), + readTimeout = Pair(DEFAULT_READ_TIMEOUT, TimeUnit.MILLISECONDS), + headers = conceptHeaders, + // Make sure we are not sending cookies. Unfortunately, HttpURLConnection doesn't + // offer a better API to do that, so we nuke all cookies going to our telemetry + // endpoint. + cookiePolicy = Request.CookiePolicy.OMIT, + body = Request.Body(data.inputStream()), + private = usePrivateRequest, + conservative = true, + ) + } + + @Throws(IOException::class) + internal fun performUpload(client: Client, request: Request): UploadResult { + logger.debug("Submitting ping to: ${request.url}") + client.fetch(request).use { response -> + return HttpStatus(response.status) + } + } +} diff --git a/mobile/android/android-components/components/service/glean/src/main/java/mozilla/components/service/glean/private/MetricAliases.kt b/mobile/android/android-components/components/service/glean/src/main/java/mozilla/components/service/glean/private/MetricAliases.kt new file mode 100644 index 0000000000..e6e0be9424 --- /dev/null +++ b/mobile/android/android-components/components/service/glean/src/main/java/mozilla/components/service/glean/private/MetricAliases.kt @@ -0,0 +1,119 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.service.glean.private + +import androidx.annotation.VisibleForTesting + +typealias CommonMetricData = mozilla.telemetry.glean.private.CommonMetricData +typealias EventExtras = mozilla.telemetry.glean.private.EventExtras +typealias Lifetime = mozilla.telemetry.glean.private.Lifetime +typealias NoExtras = mozilla.telemetry.glean.private.NoExtras +typealias NoReasonCodes = mozilla.telemetry.glean.private.NoReasonCodes +typealias ReasonCode = mozilla.telemetry.glean.private.ReasonCode + +typealias BooleanMetricType = mozilla.telemetry.glean.private.BooleanMetricType +typealias CounterMetricType = mozilla.telemetry.glean.private.CounterMetricType +typealias CustomDistributionMetricType = mozilla.telemetry.glean.private.CustomDistributionMetricType +typealias DatetimeMetricType = mozilla.telemetry.glean.private.DatetimeMetricType +typealias DenominatorMetricType = mozilla.telemetry.glean.private.DenominatorMetricType +typealias HistogramMetricBase = mozilla.telemetry.glean.private.HistogramBase +typealias HistogramType = mozilla.telemetry.glean.private.HistogramType +typealias LabeledMetricType<T> = mozilla.telemetry.glean.private.LabeledMetricType<T> +typealias MemoryDistributionMetricType = mozilla.telemetry.glean.private.MemoryDistributionMetricType +typealias MemoryUnit = mozilla.telemetry.glean.private.MemoryUnit +typealias NumeratorMetricType = mozilla.telemetry.glean.private.NumeratorMetricType +typealias PingType<T> = mozilla.telemetry.glean.private.PingType<T> +typealias QuantityMetricType = mozilla.telemetry.glean.private.QuantityMetricType +typealias RateMetricType = mozilla.telemetry.glean.private.RateMetricType +typealias RecordedExperiment = mozilla.telemetry.glean.private.RecordedExperiment +typealias StringListMetricType = mozilla.telemetry.glean.private.StringListMetricType +typealias StringMetricType = mozilla.telemetry.glean.private.StringMetricType +typealias TextMetricType = mozilla.telemetry.glean.private.TextMetricType +typealias TimeUnit = mozilla.telemetry.glean.private.TimeUnit +typealias TimespanMetricType = mozilla.telemetry.glean.private.TimespanMetricType +typealias TimingDistributionMetricType = mozilla.telemetry.glean.private.TimingDistributionMetricType +typealias UrlMetricType = mozilla.telemetry.glean.private.UrlMetricType +typealias UuidMetricType = mozilla.telemetry.glean.private.UuidMetricType + +// FIXME(bug 1885170): Wrap the Glean SDK `EventMetricType` to overwrite the `testGetValue` function. +/** + * This implements the developer facing API for recording events. + * + * Instances of this class type are automatically generated by the parsers at built time, + * allowing developers to record events that were previously registered in the metrics.yaml file. + * + * The Events API only exposes the [record] method, which takes care of validating the input + * data and making sure that limits are enforced. + */ +class EventMetricType<ExtraObject> internal constructor( + private var inner: mozilla.telemetry.glean.private.EventMetricType<ExtraObject>, +) where ExtraObject : EventExtras { + /** + * The public constructor used by automatically generated metrics. + */ + constructor(meta: CommonMetricData, allowedExtraKeys: List<String>) : + this(inner = mozilla.telemetry.glean.private.EventMetricType(meta, allowedExtraKeys)) + + /** + * Record an event by using the information provided by the instance of this class. + * + * @param extra The event extra properties. + * Values are converted to strings automatically + * This is used for events where additional richer context is needed. + * The maximum length for values is 100 bytes. + * + * Note: `extra` is not optional here to avoid overlapping with the above definition of `record`. + * If no `extra` data is passed the above function will be invoked correctly. + */ + fun record(extra: ExtraObject? = null) { + inner.record(extra) + } + + /** + * Returns the stored value for testing purposes only. This function will attempt to await the + * last task (if any) writing to the the metric's storage engine before returning a value. + * + * @param pingName represents the name of the ping to retrieve the metric for. + * Defaults to the first value in `sendInPings`. + * @return value of the stored events + */ + @VisibleForTesting(otherwise = VisibleForTesting.NONE) + @JvmOverloads + fun testGetValue(pingName: String? = null): List<mozilla.telemetry.glean.private.RecordedEvent>? { + var events = inner.testGetValue(pingName) + if (events == null) { + return events + } + + // Remove the `glean_timestamp` extra. + // This is added by Glean and does not need to be exposed to testing. + for (event in events) { + if (event.extra == null) { + continue + } + + // We know it's not null + var map = event.extra!!.toMutableMap() + map.remove("glean_timestamp") + if (map.isEmpty()) { + event.extra = null + } else { + event.extra = map + } + } + + return events + } + + /** + * Returns the number of errors recorded for the given metric. + * + * @param errorType The type of the error recorded. + * @return the number of errors recorded for the metric. + */ + @VisibleForTesting(otherwise = VisibleForTesting.NONE) + fun testGetNumRecordedErrors(errorType: mozilla.components.service.glean.testing.ErrorType) = + inner.testGetNumRecordedErrors(errorType) +} diff --git a/mobile/android/android-components/components/service/glean/src/main/java/mozilla/components/service/glean/testing/ErrorType.kt b/mobile/android/android-components/components/service/glean/src/main/java/mozilla/components/service/glean/testing/ErrorType.kt new file mode 100644 index 0000000000..8f4e53d501 --- /dev/null +++ b/mobile/android/android-components/components/service/glean/src/main/java/mozilla/components/service/glean/testing/ErrorType.kt @@ -0,0 +1,10 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.service.glean.testing + +/** + * Different types of errors that can be reported through Glean's error reporting metrics. + */ +typealias ErrorType = mozilla.telemetry.glean.testing.ErrorType diff --git a/mobile/android/android-components/components/service/glean/src/main/java/mozilla/components/service/glean/testing/GleanTestLocalServer.kt b/mobile/android/android-components/components/service/glean/src/main/java/mozilla/components/service/glean/testing/GleanTestLocalServer.kt new file mode 100644 index 0000000000..0a02b953bb --- /dev/null +++ b/mobile/android/android-components/components/service/glean/src/main/java/mozilla/components/service/glean/testing/GleanTestLocalServer.kt @@ -0,0 +1,31 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.service.glean.testing + +/** + * This implements a JUnit rule for writing tests for Glean SDK metrics. + * + * The rule takes care of sending Glean SDK pings to a local server, at the + * address: "http://localhost:<port>". + * + * This is useful for Android instrumented tests, where we don't want to + * initialize Glean more than once but still want to send pings to a local + * server for validation. + * + * FIXME(bug 1787234): State of the local server can persist across multiple test classes, + * leading to hard-to-diagnose intermittent test failures. + * It might be necessary to limit use of `GleanTestLocalServer` to a single test class for now. + * + * Example usage: + * + * ``` + * // Add the following lines to you test class. + * @get:Rule + * val gleanRule = GleanTestLocalServer(3785) + * ``` + * + * @param localPort the port of the local ping server + */ +typealias GleanTestLocalServer = mozilla.telemetry.glean.testing.GleanTestLocalServer diff --git a/mobile/android/android-components/components/service/glean/src/main/java/mozilla/components/service/glean/testing/GleanTestRule.kt b/mobile/android/android-components/components/service/glean/src/main/java/mozilla/components/service/glean/testing/GleanTestRule.kt new file mode 100644 index 0000000000..91d20fad70 --- /dev/null +++ b/mobile/android/android-components/components/service/glean/src/main/java/mozilla/components/service/glean/testing/GleanTestRule.kt @@ -0,0 +1,7 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.service.glean.testing + +typealias GleanTestRule = mozilla.telemetry.glean.testing.GleanTestRule diff --git a/mobile/android/android-components/components/service/glean/src/test/java/mozilla/components/service/glean/GleanFromJavaTest.java b/mobile/android/android-components/components/service/glean/src/test/java/mozilla/components/service/glean/GleanFromJavaTest.java new file mode 100644 index 0000000000..c495e68bbc --- /dev/null +++ b/mobile/android/android-components/components/service/glean/src/test/java/mozilla/components/service/glean/GleanFromJavaTest.java @@ -0,0 +1,68 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.service.glean; + +import androidx.test.core.app.ApplicationProvider; +import androidx.work.testing.WorkManagerTestInitHelper; + +import android.content.Context; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; + +import java.util.Calendar; +import java.util.HashMap; +import java.util.Map; + +import mozilla.components.lib.fetch.httpurlconnection.HttpURLConnectionClient; +import mozilla.components.service.glean.config.Configuration; +import mozilla.components.service.glean.net.ConceptFetchHttpUploader; +import mozilla.telemetry.glean.BuildInfo; + +@RunWith(RobolectricTestRunner.class) +public class GleanFromJavaTest { + // The only purpose of these tests is to make sure the Glean API is + // callable from Java. If something goes wrong, it should complain about missing + // methods at build-time. + + @Test + public void testInitGleanWithDefaults() { + Context context = ApplicationProvider.getApplicationContext(); + WorkManagerTestInitHelper.initializeTestWorkManager(context); + ConceptFetchHttpUploader httpClient = ConceptFetchHttpUploader.fromClient(new HttpURLConnectionClient()); + Configuration config = new Configuration(httpClient); + BuildInfo buildInfo = new BuildInfo("test", "test", Calendar.getInstance()); + Glean.INSTANCE.initialize(context, true, config, buildInfo); + } + + @Test + public void testInitGleanWithConfiguration() { + Context context = ApplicationProvider.getApplicationContext(); + WorkManagerTestInitHelper.initializeTestWorkManager(context); + ConceptFetchHttpUploader httpClient = ConceptFetchHttpUploader.fromClient(new HttpURLConnectionClient()); + Configuration config = + new Configuration(httpClient, Configuration.DEFAULT_TELEMETRY_ENDPOINT, "test-channel"); + BuildInfo buildInfo = new BuildInfo("test", "test", Calendar.getInstance()); + Glean.INSTANCE.initialize(context, true, config, buildInfo); + } + + @Test + public void testGleanExperimentsAPIWithDefaults() { + Glean.INSTANCE.setExperimentActive("test-exp-id-1", "test-branch-1"); + } + + @Test + public void testGleanExperimentsAPIWithOptional() { + Map<String, String> experimentProperties = new HashMap<>(); + experimentProperties.put("test-prop1", "test-prop-result1"); + + Glean.INSTANCE.setExperimentActive( + "test-exp-id-1", + "test-branch-1", + experimentProperties + ); + } +} diff --git a/mobile/android/android-components/components/service/glean/src/test/java/mozilla/components/service/glean/GleanTest.kt b/mobile/android/android-components/components/service/glean/src/test/java/mozilla/components/service/glean/GleanTest.kt new file mode 100644 index 0000000000..7eaae408f2 --- /dev/null +++ b/mobile/android/android-components/components/service/glean/src/test/java/mozilla/components/service/glean/GleanTest.kt @@ -0,0 +1,44 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.service.glean + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import mozilla.components.service.glean.private.BooleanMetricType +import mozilla.components.service.glean.private.CommonMetricData +import mozilla.components.service.glean.private.Lifetime +import mozilla.components.service.glean.testing.GleanTestRule +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class GleanTest { + private val context: Context + get() = ApplicationProvider.getApplicationContext() + + @get:Rule + val gleanRule = GleanTestRule(context) + + @Test + fun `Glean correctly initializes and records a metric`() { + // Define a 'booleanMetric' boolean metric, which will be stored in "store1" + val booleanMetric = BooleanMetricType( + CommonMetricData( + disabled = false, + category = "telemetry", + lifetime = Lifetime.APPLICATION, + name = "boolean_metric", + sendInPings = listOf("store1"), + ), + ) + + booleanMetric.set(true) + + assertTrue(booleanMetric.testGetValue()!!) + } +} diff --git a/mobile/android/android-components/components/service/glean/src/test/java/mozilla/components/service/glean/net/ConceptFetchHttpUploaderTest.kt b/mobile/android/android-components/components/service/glean/src/test/java/mozilla/components/service/glean/net/ConceptFetchHttpUploaderTest.kt new file mode 100644 index 0000000000..48d0d6c3a4 --- /dev/null +++ b/mobile/android/android-components/components/service/glean/src/test/java/mozilla/components/service/glean/net/ConceptFetchHttpUploaderTest.kt @@ -0,0 +1,340 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.service.glean.net + +import mozilla.components.concept.fetch.Client +import mozilla.components.concept.fetch.Request +import mozilla.components.concept.fetch.Response +import mozilla.components.lib.fetch.httpurlconnection.HttpURLConnectionClient +import mozilla.components.lib.fetch.okhttp.OkHttpClient +import mozilla.components.support.test.any +import mozilla.components.support.test.argumentCaptor +import mozilla.components.support.test.mock +import mozilla.telemetry.glean.config.Configuration +import mozilla.telemetry.glean.net.HttpStatus +import mozilla.telemetry.glean.net.RecoverableFailure +import okhttp3.mockwebserver.Dispatcher +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okhttp3.mockwebserver.RecordedRequest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.mockito.Mockito.spy +import org.mockito.Mockito.verify +import org.mockito.Mockito.`when` +import java.io.IOException +import java.net.CookieHandler +import java.net.CookieManager +import java.net.HttpCookie +import java.net.URI +import java.util.concurrent.TimeUnit + +class ConceptFetchHttpUploaderTest { + private val testPath: String = "/some/random/path/not/important" + private val testPing: String = "{ 'ping': 'test' }" + private val testDefaultConfig = Configuration() + + /** + * Create a mock webserver that accepts all requests. + * @return a [MockWebServer] instance + */ + private fun getMockWebServer(): MockWebServer { + val server = MockWebServer() + server.dispatcher = + object : Dispatcher() { + override fun dispatch(request: RecordedRequest): MockResponse { + return MockResponse().setBody("OK") + } + } + + return server + } + + @Test + fun `connection timeouts must be properly set`() { + val uploader = + spy<ConceptFetchHttpUploader>(ConceptFetchHttpUploader(lazy { HttpURLConnectionClient() })) + + val request = uploader.buildRequest(testPath, testPing.toByteArray(), emptyMap()) + + assertEquals( + Pair(ConceptFetchHttpUploader.DEFAULT_READ_TIMEOUT, TimeUnit.MILLISECONDS), + request.readTimeout, + ) + assertEquals( + Pair(ConceptFetchHttpUploader.DEFAULT_CONNECTION_TIMEOUT, TimeUnit.MILLISECONDS), + request.connectTimeout, + ) + } + + @Test + fun `Glean headers are correctly dispatched`() { + val mockClient: Client = mock() + `when`(mockClient.fetch(any())).thenReturn( + Response("URL", 200, mock(), mock()), + ) + + val expectedHeaders = mapOf( + "Content-Type" to "application/json; charset=utf-8", + "Test-header" to "SomeValue", + "OtherHeader" to "Glean/Test 25.0.2", + ) + + val uploader = ConceptFetchHttpUploader(lazy { mockClient }) + uploader.upload(testPath, testPing.toByteArray(), expectedHeaders) + val requestCaptor = argumentCaptor<Request>() + verify(mockClient).fetch(requestCaptor.capture()) + + expectedHeaders.forEach { (headerName, headerValue) -> + assertEquals( + headerValue, + requestCaptor.value.headers!![headerName], + ) + } + } + + @Test + fun `Cookie policy must be properly set`() { + val uploader = + spy<ConceptFetchHttpUploader>(ConceptFetchHttpUploader(lazy { HttpURLConnectionClient() })) + + val request = uploader.buildRequest(testPath, testPing.toByteArray(), emptyMap()) + + assertEquals(request.cookiePolicy, Request.CookiePolicy.OMIT) + } + + @Test + fun `upload() returns true for successful submissions (200)`() { + val mockClient: Client = mock() + `when`(mockClient.fetch(any())).thenReturn( + Response( + "URL", + 200, + mock(), + mock(), + ), + ) + + val uploader = spy<ConceptFetchHttpUploader>(ConceptFetchHttpUploader(lazy { mockClient })) + + assertEquals(HttpStatus(200), uploader.upload(testPath, testPing.toByteArray(), emptyMap())) + } + + @Test + fun `upload() returns false for server errors (5xx)`() { + for (responseCode in 500..527) { + val mockClient: Client = mock() + `when`(mockClient.fetch(any())).thenReturn( + Response( + "URL", + responseCode, + mock(), + mock(), + ), + ) + + val uploader = spy<ConceptFetchHttpUploader>(ConceptFetchHttpUploader(lazy { mockClient })) + + assertEquals(HttpStatus(responseCode), uploader.upload(testPath, testPing.toByteArray(), emptyMap())) + } + } + + @Test + fun `upload() returns true for successful submissions (2xx)`() { + for (responseCode in 200..226) { + val mockClient: Client = mock() + `when`(mockClient.fetch(any())).thenReturn( + Response( + "URL", + responseCode, + mock(), + mock(), + ), + ) + + val uploader = spy<ConceptFetchHttpUploader>(ConceptFetchHttpUploader(lazy { mockClient })) + + assertEquals(HttpStatus(responseCode), uploader.upload(testPath, testPing.toByteArray(), emptyMap())) + } + } + + @Test + fun `upload() returns true for failing submissions with broken requests (4xx)`() { + for (responseCode in 400..451) { + val mockClient: Client = mock() + `when`(mockClient.fetch(any())).thenReturn( + Response( + "URL", + responseCode, + mock(), + mock(), + ), + ) + + val uploader = spy<ConceptFetchHttpUploader>(ConceptFetchHttpUploader(lazy { mockClient })) + + assertEquals(HttpStatus(responseCode), uploader.upload(testPath, testPing.toByteArray(), emptyMap())) + } + } + + @Test + fun `upload() correctly uploads the ping data with default configuration`() { + val server = getMockWebServer() + + val client = ConceptFetchHttpUploader(lazy { HttpURLConnectionClient() }) + + val submissionUrl = "http://" + server.hostName + ":" + server.port + testPath + assertEquals(HttpStatus(200), client.upload(submissionUrl, testPing.toByteArray(), mapOf("test" to "header"))) + + val request = server.takeRequest() + assertEquals(testPath, request.path) + assertEquals("POST", request.method) + assertEquals(testPing, request.body.readUtf8()) + assertEquals("header", request.getHeader("test")) + + server.shutdown() + } + + @Test + fun `upload() correctly uploads the ping data with httpurlconnection client`() { + val server = getMockWebServer() + + val client = ConceptFetchHttpUploader(lazy { HttpURLConnectionClient() }) + + val submissionUrl = "http://" + server.hostName + ":" + server.port + testPath + assertEquals(HttpStatus(200), client.upload(submissionUrl, testPing.toByteArray(), mapOf("test" to "header"))) + + val request = server.takeRequest() + assertEquals(testPath, request.path) + assertEquals("POST", request.method) + assertEquals(testPing, request.body.readUtf8()) + assertEquals("header", request.getHeader("test")) + assertTrue(request.headers.values("Cookie").isEmpty()) + + server.shutdown() + } + + @Test + fun `upload() correctly uploads the ping data with OkHttp client`() { + val server = getMockWebServer() + + val client = ConceptFetchHttpUploader(lazy { OkHttpClient() }) + + val submissionUrl = "http://" + server.hostName + ":" + server.port + testPath + assertEquals(HttpStatus(200), client.upload(submissionUrl, testPing.toByteArray(), mapOf("test" to "header"))) + + val request = server.takeRequest() + assertEquals(testPath, request.path) + assertEquals("POST", request.method) + assertEquals(testPing, request.body.readUtf8()) + assertEquals("header", request.getHeader("test")) + assertTrue(request.headers.values("Cookie").isEmpty()) + + server.shutdown() + } + + @Test + fun `upload() must not transmit any cookie`() { + val server = getMockWebServer() + + val testConfig = testDefaultConfig.copy( + serverEndpoint = "http://localhost:" + server.port, + ) + + // Set the default cookie manager/handler to be used for the http upload. + val cookieManager = CookieManager() + CookieHandler.setDefault(cookieManager) + + // Store a sample cookie. + val cookie = HttpCookie("cookie-time", "yes") + cookie.domain = testConfig.serverEndpoint + cookie.path = testPath + cookie.version = 0 + cookieManager.cookieStore.add(URI(testConfig.serverEndpoint), cookie) + + // Store a cookie for a subdomain of the same domain's as the server endpoint, + // to make sure we don't accidentally remove it. + val cookie2 = HttpCookie("cookie-time2", "yes") + cookie2.domain = "sub.localhost" + cookie2.path = testPath + cookie2.version = 0 + cookieManager.cookieStore.add(URI("http://sub.localhost:${server.port}/test"), cookie2) + + // Add another cookie for the same domain. This one should be removed as well. + val cookie3 = HttpCookie("cookie-time3", "yes") + cookie3.domain = "localhost" + cookie3.path = testPath + cookie3.version = 0 + cookieManager.cookieStore.add(URI("http://localhost:${server.port}/test"), cookie3) + + // Trigger the connection. + val client = ConceptFetchHttpUploader(lazy { HttpURLConnectionClient() }) + val submissionUrl = testConfig.serverEndpoint + testPath + assertEquals(HttpStatus(200), client.upload(submissionUrl, testPing.toByteArray(), emptyMap())) + + val request = server.takeRequest() + assertEquals(testPath, request.path) + assertEquals("POST", request.method) + assertEquals(testPing, request.body.readUtf8()) + assertTrue(request.headers.values("Cookie").isEmpty()) + + // Check that we still have a cookie. + assertEquals(1, cookieManager.cookieStore.cookies.size) + assertEquals("cookie-time2", cookieManager.cookieStore.cookies[0].name) + + server.shutdown() + } + + @Test + fun `upload() should return false when upload fails`() { + val mockClient: Client = mock() + `when`(mockClient.fetch(any())).thenThrow(IOException()) + + val uploader = spy<ConceptFetchHttpUploader>(ConceptFetchHttpUploader(lazy { mockClient })) + + // And IOException during upload is a failed upload that we should retry. The client should + // return false in this case. + assertEquals(RecoverableFailure(0), uploader.upload("path", "ping".toByteArray(), emptyMap())) + } + + @Test + fun `the lazy client should only be instantiated after the first upload`() { + val mockClient: Client = mock() + `when`(mockClient.fetch(any())).thenReturn( + Response("URL", 200, mock(), mock()), + ) + val uploader = spy<ConceptFetchHttpUploader>(ConceptFetchHttpUploader(lazy { mockClient })) + assertFalse(uploader.client.isInitialized()) + + // After calling upload, the client must get instantiated. + uploader.upload("path", "ping".toByteArray(), emptyMap()) + assertTrue(uploader.client.isInitialized()) + } + + @Test + fun `usePrivateRequest sends all requests with private flag`() { + val mockClient: Client = mock() + `when`(mockClient.fetch(any())).thenReturn( + Response("URL", 200, mock(), mock()), + ) + + val expectedHeaders = mapOf( + "Content-Type" to "application/json; charset=utf-8", + "Test-header" to "SomeValue", + "OtherHeader" to "Glean/Test 25.0.2", + ) + + val uploader = ConceptFetchHttpUploader(lazy { mockClient }, true) + uploader.upload(testPath, testPing.toByteArray(), expectedHeaders) + + val captor = argumentCaptor<Request>() + + verify(mockClient).fetch(captor.capture()) + + assertTrue(captor.value.private) + } +} diff --git a/mobile/android/android-components/components/service/glean/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker b/mobile/android/android-components/components/service/glean/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker new file mode 100644 index 0000000000..1f0955d450 --- /dev/null +++ b/mobile/android/android-components/components/service/glean/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker @@ -0,0 +1 @@ +mock-maker-inline diff --git a/mobile/android/android-components/components/service/glean/src/test/resources/robolectric.properties b/mobile/android/android-components/components/service/glean/src/test/resources/robolectric.properties new file mode 100644 index 0000000000..932b01b9eb --- /dev/null +++ b/mobile/android/android-components/components/service/glean/src/test/resources/robolectric.properties @@ -0,0 +1 @@ +sdk=28 |