From 02f6e6868ead58718391428a64f81b17b367a6f6 Mon Sep 17 00:00:00 2001 From: Sawyer Blatz Date: Thu, 7 May 2020 08:57:20 -0700 Subject: [PATCH] For #10426: Adds identifier to Glean for 24 hours (#10446) --- app/metrics.yaml | 13 ++ .../components/metrics/ActivationPing.kt | 99 +------------- .../components/metrics/InstallationPing.kt | 5 + .../fenix/components/metrics/MetricsUtils.kt | 100 ++++++++++++++ .../components/metrics/ActivationPingTest.kt | 89 ------------- .../components/metrics/MetricsUtilsTest.kt | 122 ++++++++++++------ .../metrics/MetricsUtilsTestRoboelectric.kt | 63 +++++++++ docs/metrics.md | 1 + 8 files changed, 265 insertions(+), 227 deletions(-) create mode 100644 app/src/test/java/org/mozilla/fenix/components/metrics/MetricsUtilsTestRoboelectric.kt diff --git a/app/metrics.yaml b/app/metrics.yaml index 2a81f9483..f6e6f7055 100644 --- a/app/metrics.yaml +++ b/app/metrics.yaml @@ -2073,6 +2073,19 @@ installation: notification_emails: - fenix-core@mozilla.com expires: "2020-09-01" + identifier: + send_in_pings: + - installation + type: string + description: | + The hashed and salted GAID. Used for a short term installation validation test. + bugs: + - https://github.com/mozilla-mobile/fenix/issues/10426 + data_reviews: + - https://github.com/mozilla-mobile/fenix/pull/10446#issuecomment-624816258 + notification_emails: + - fenix-core@mozilla.com + expires: "2020-05-10" browser.search: with_ads: diff --git a/app/src/main/java/org/mozilla/fenix/components/metrics/ActivationPing.kt b/app/src/main/java/org/mozilla/fenix/components/metrics/ActivationPing.kt index 0101b2b9c..7b8dc8b8a 100644 --- a/app/src/main/java/org/mozilla/fenix/components/metrics/ActivationPing.kt +++ b/app/src/main/java/org/mozilla/fenix/components/metrics/ActivationPing.kt @@ -6,23 +6,14 @@ package org.mozilla.fenix.components.metrics import android.content.Context import android.content.SharedPreferences -import android.util.Base64 import androidx.annotation.VisibleForTesting -import com.google.android.gms.ads.identifier.AdvertisingIdClient -import com.google.android.gms.common.GooglePlayServicesNotAvailableException -import com.google.android.gms.common.GooglePlayServicesRepairableException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext import mozilla.components.support.base.log.logger.Logger import org.mozilla.fenix.GleanMetrics.Activation import org.mozilla.fenix.GleanMetrics.Pings -import java.io.IOException -import java.security.NoSuchAlgorithmException -import java.security.spec.InvalidKeySpecException -import javax.crypto.SecretKeyFactory -import javax.crypto.spec.PBEKeySpec +import org.mozilla.fenix.components.metrics.MetricsUtils.getHashedIdentifier class ActivationPing(private val context: Context) { companion object { @@ -62,92 +53,6 @@ class ActivationPing(private val context: Context) { prefs.edit().putBoolean("ping_sent", true).apply() } - /** - * Query the Google Advertising API to get the Google Advertising ID. - * - * This is meant to be used off the main thread. The API will throw an - * exception and we will print a log message otherwise. - * - * @return a String containing the Google Advertising ID or null. - */ - @Suppress("TooGenericExceptionCaught") - @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) - internal fun getAdvertisingID(): String? { - return try { - AdvertisingIdClient.getAdvertisingIdInfo(context).id - } catch (e: GooglePlayServicesNotAvailableException) { - Logger.debug("ActivationPing - Google Play not installed on the device") - null - } catch (e: GooglePlayServicesRepairableException) { - Logger.debug("ActivationPing - recoverable error connecting to Google Play Services") - null - } catch (e: IllegalStateException) { - // This is unlikely to happen, as this should be running off the main thread. - Logger.debug("ActivationPing - AdvertisingIdClient must be called off the main thread") - null - } catch (e: IOException) { - Logger.debug("ActivationPing - unable to connect to Google Play Services") - null - } catch (e: NullPointerException) { - Logger.debug("ActivationPing - no Google Advertising ID available") - null - } - } - - /** - * Get the salt to use for hashing. This is a convenience - * function to help with unit tests. - */ - @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) - internal fun getHashingSalt(): String = "org.mozilla.fenix-salt" - - /** - * Produces an hashed version of the Google Advertising ID. - * We want users using more than one of our products to report a different - * ID in each of them. This function runs off the main thread and is CPU-bound. - * - * @return an hashed and salted Google Advertising ID or null if it was not possible - * to get the Google Advertising ID. - */ - @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) - internal suspend fun getHashedIdentifier(): String? = withContext(Dispatchers.Default) { - getAdvertisingID()?.let { unhashedID -> - // Add some salt to the ID, before hashing. For this specific use-case, it's ok - // to use the same salt value for all the hashes. We want hashes to be stable - // within a single product, but we don't want hashes to be the same across different - // products (e.g. Fennec vs Fenix). - val salt = getHashingSalt() - - // Apply hashing. - try { - // Note that we intentionally want to use slow hashing functions here in order - // to increase the cost of potentially repeatedly guess the original unhashed - // identifier. - val keySpec = PBEKeySpec( - unhashedID.toCharArray(), - salt.toByteArray(), - PBKDF2_ITERATIONS, - PBKDF2_KEY_LEN_BITS) - - val keyFactory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA1") - val hashedBytes = keyFactory.generateSecret(keySpec).encoded - Base64.encodeToString(hashedBytes, Base64.NO_WRAP) - } catch (e: java.lang.NullPointerException) { - Logger.error("ActivationPing - missing or wrong salt parameter") - null - } catch (e: IllegalArgumentException) { - Logger.error("ActivationPing - wrong parameter", e) - null - } catch (e: NoSuchAlgorithmException) { - Logger.error("ActivationPing - algorithm not available") - null - } catch (e: InvalidKeySpecException) { - Logger.error("ActivationPing - invalid key spec") - null - } - } - } - /** * Fills the metrics and triggers the 'activation' ping. * This is a separate function to simplify unit-testing. @@ -158,7 +63,7 @@ class ActivationPing(private val context: Context) { Activation.activationId.generateAndSet() CoroutineScope(Dispatchers.IO).launch { - val hashedId = getHashedIdentifier() + val hashedId = getHashedIdentifier(context) if (hashedId != null) { Logger.info("ActivationPing - generating ping with the hashed id") // We have a valid, hashed Google Advertising ID. diff --git a/app/src/main/java/org/mozilla/fenix/components/metrics/InstallationPing.kt b/app/src/main/java/org/mozilla/fenix/components/metrics/InstallationPing.kt index 4196d3621..4478d565f 100644 --- a/app/src/main/java/org/mozilla/fenix/components/metrics/InstallationPing.kt +++ b/app/src/main/java/org/mozilla/fenix/components/metrics/InstallationPing.kt @@ -11,6 +11,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import mozilla.components.support.base.log.logger.Logger +import org.mozilla.fenix.GleanMetrics.Activation import org.mozilla.fenix.GleanMetrics.Installation import org.mozilla.fenix.GleanMetrics.Pings import org.mozilla.fenix.ext.settings @@ -64,6 +65,10 @@ class InstallationPing(private val context: Context) { } CoroutineScope(Dispatchers.IO).launch { + MetricsUtils.getHashedIdentifier(context)?.let { + Activation.identifier.set(it) + } + Pings.installation.submit() markAsTriggered() } diff --git a/app/src/main/java/org/mozilla/fenix/components/metrics/MetricsUtils.kt b/app/src/main/java/org/mozilla/fenix/components/metrics/MetricsUtils.kt index bff7c48fc..2c9bf3eff 100644 --- a/app/src/main/java/org/mozilla/fenix/components/metrics/MetricsUtils.kt +++ b/app/src/main/java/org/mozilla/fenix/components/metrics/MetricsUtils.kt @@ -5,10 +5,23 @@ package org.mozilla.fenix.components.metrics import android.content.Context +import android.util.Base64 +import androidx.annotation.VisibleForTesting +import com.google.android.gms.ads.identifier.AdvertisingIdClient +import com.google.android.gms.common.GooglePlayServicesNotAvailableException +import com.google.android.gms.common.GooglePlayServicesRepairableException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import mozilla.components.browser.search.SearchEngine +import mozilla.components.support.base.log.logger.Logger import org.mozilla.fenix.components.metrics.Event.PerformedSearch.SearchAccessPoint import org.mozilla.fenix.components.searchengine.CustomSearchEngineStore import org.mozilla.fenix.ext.searchEngineManager +import java.io.IOException +import java.security.NoSuchAlgorithmException +import java.security.spec.InvalidKeySpecException +import javax.crypto.SecretKeyFactory +import javax.crypto.spec.PBEKeySpec object MetricsUtils { fun createSearchEvent( @@ -51,4 +64,91 @@ object MetricsUtils { ) } } + + /** + * Get the salt to use for hashing. This is a convenience + * function to help with unit tests. + */ + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + internal fun getHashingSalt(): String = "org.mozilla.fenix-salt" + + /** + * Query the Google Advertising API to get the Google Advertising ID. + * + * This is meant to be used off the main thread. The API will throw an + * exception and we will print a log message otherwise. + * + * @return a String containing the Google Advertising ID or null. + */ + @Suppress("TooGenericExceptionCaught") + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + internal fun getAdvertisingID(context: Context): String? { + return try { + AdvertisingIdClient.getAdvertisingIdInfo(context).id + } catch (e: GooglePlayServicesNotAvailableException) { + Logger.debug("ActivationPing - Google Play not installed on the device") + null + } catch (e: GooglePlayServicesRepairableException) { + Logger.debug("ActivationPing - recoverable error connecting to Google Play Services") + null + } catch (e: IllegalStateException) { + // This is unlikely to happen, as this should be running off the main thread. + Logger.debug("ActivationPing - AdvertisingIdClient must be called off the main thread") + null + } catch (e: IOException) { + Logger.debug("ActivationPing - unable to connect to Google Play Services") + null + } catch (e: NullPointerException) { + Logger.debug("ActivationPing - no Google Advertising ID available") + null + } + } + + /** + * Produces a hashed version of the Google Advertising ID. + * We want users using more than one of our products to report a different + * ID in each of them. This function runs off the main thread and is CPU-bound. + * + * @return an hashed and salted Google Advertising ID or null if it was not possible + * to get the Google Advertising ID. + */ + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + suspend fun getHashedIdentifier(context: Context): String? = withContext(Dispatchers.Default) { + getAdvertisingID(context)?.let { unhashedID -> + // Add some salt to the ID, before hashing. For this specific use-case, it's ok + // to use the same salt value for all the hashes. We want hashes to be stable + // within a single product, but we don't want hashes to be the same across different + // products (e.g. Fennec vs Fenix). + val salt = getHashingSalt() + + // Apply hashing. + try { + // Note that we intentionally want to use slow hashing functions here in order + // to increase the cost of potentially repeatedly guess the original unhashed + // identifier. + val keySpec = PBEKeySpec( + unhashedID.toCharArray(), + salt.toByteArray(), + ActivationPing.PBKDF2_ITERATIONS, + ActivationPing.PBKDF2_KEY_LEN_BITS + ) + + val keyFactory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA1") + val hashedBytes = keyFactory.generateSecret(keySpec).encoded + Base64.encodeToString(hashedBytes, Base64.NO_WRAP) + } catch (e: java.lang.NullPointerException) { + Logger.error("ActivationPing - missing or wrong salt parameter") + null + } catch (e: IllegalArgumentException) { + Logger.error("ActivationPing - wrong parameter", e) + null + } catch (e: NoSuchAlgorithmException) { + Logger.error("ActivationPing - algorithm not available") + null + } catch (e: InvalidKeySpecException) { + Logger.error("ActivationPing - invalid key spec") + null + } + } + } } diff --git a/app/src/test/java/org/mozilla/fenix/components/metrics/ActivationPingTest.kt b/app/src/test/java/org/mozilla/fenix/components/metrics/ActivationPingTest.kt index 0892e1cc7..d038c3165 100644 --- a/app/src/test/java/org/mozilla/fenix/components/metrics/ActivationPingTest.kt +++ b/app/src/test/java/org/mozilla/fenix/components/metrics/ActivationPingTest.kt @@ -4,104 +4,15 @@ package org.mozilla.fenix.components.metrics -import android.util.Base64 -import com.google.android.gms.ads.identifier.AdvertisingIdClient -import com.google.android.gms.common.GooglePlayServicesNotAvailableException -import com.google.android.gms.common.GooglePlayServicesRepairableException import io.mockk.Runs import io.mockk.every import io.mockk.just import io.mockk.mockk -import io.mockk.mockkStatic -import io.mockk.slot import io.mockk.spyk import io.mockk.verify -import kotlinx.coroutines.runBlocking -import org.junit.Assert.assertEquals -import org.junit.Assert.assertNull -import org.junit.Ignore import org.junit.Test -import org.mockito.ArgumentMatchers.any -import org.mockito.ArgumentMatchers.anyString -import java.io.IOException internal class ActivationPingTest { - @Ignore("This test has side-effects that cause it to fail other unrelated tests.") - @Test - fun `getAdvertisingID() returns null if the API throws`() { - mockkStatic(AdvertisingIdClient::class) - - val exceptions = listOf( - GooglePlayServicesNotAvailableException(1), - GooglePlayServicesRepairableException(0, anyString(), any()), - IllegalStateException(), - IOException() - ) - - val ap = ActivationPing(mockk()) - exceptions.forEach { - every { - AdvertisingIdClient.getAdvertisingIdInfo(any()) - } throws it - - assertNull(ap.getAdvertisingID()) - } - } - - @Test - fun `getAdvertisingID() returns null if the API returns null info`() { - mockkStatic(AdvertisingIdClient::class) - every { AdvertisingIdClient.getAdvertisingIdInfo(any()) } returns null - - val ap = ActivationPing(mockk()) - assertNull(ap.getAdvertisingID()) - } - - @Test - fun `getAdvertisingID() returns a valid string if the API returns a valid ID`() { - val testId = "test-value-id" - - mockkStatic(AdvertisingIdClient::class) - every { - AdvertisingIdClient.getAdvertisingIdInfo(any()) - } returns AdvertisingIdClient.Info(testId, false) - - val ap = ActivationPing(mockk()) - assertEquals(testId, ap.getAdvertisingID()) - } - - @Test - fun `getHashedIdentifier() returns an hashed identifier`() { - val testId = "test-value-id" - val testPackageName = "org.mozilla-test.fenix" - val mockedHexReturn = "mocked-HEX" - - // Mock the Base64 to record the byte array that is passed in, - // which is the actual digest. We can't simply test the return value - // of |getHashedIdentifier| as these Android tests require us to mock - // Android-specific APIs. - mockkStatic(Base64::class) - val shaDigest = slot() - every { - Base64.encodeToString(capture(shaDigest), any()) - } returns mockedHexReturn - - // Get the hash identifier. - val mockAp = spyk(ActivationPing(mockk())) - every { mockAp.getAdvertisingID() } returns testId - every { mockAp.getHashingSalt() } returns testPackageName - runBlocking { - assertEquals(mockedHexReturn, mockAp.getHashedIdentifier()) - } - - // Check that the digest of the identifier matches with what we expect. - // Please note that in the real world, Base64.encodeToString would encode - // this to something much shorter, which we'd send with the ping. - val expectedDigestBytes = - "[52, -79, -84, 79, 101, 22, -82, -44, -44, -14, 21, 15, 48, 88, -94, -74, -8, 25, -72, -120, -37, 108, 47, 16, 2, -37, 126, 41, 102, -92, 103, 24]" - assertEquals(expectedDigestBytes, shaDigest.captured.contentToString()) - } - @Test fun `checkAndSend() triggers the ping if it wasn't marked as triggered`() { val mockAp = spyk(ActivationPing(mockk()), recordPrivateCalls = true) diff --git a/app/src/test/java/org/mozilla/fenix/components/metrics/MetricsUtilsTest.kt b/app/src/test/java/org/mozilla/fenix/components/metrics/MetricsUtilsTest.kt index d35407d23..db8efc952 100644 --- a/app/src/test/java/org/mozilla/fenix/components/metrics/MetricsUtilsTest.kt +++ b/app/src/test/java/org/mozilla/fenix/components/metrics/MetricsUtilsTest.kt @@ -1,56 +1,96 @@ package org.mozilla.fenix.components.metrics +import android.content.Context +import android.util.Base64 +import com.google.android.gms.ads.identifier.AdvertisingIdClient +import com.google.android.gms.common.GooglePlayServicesNotAvailableException +import com.google.android.gms.common.GooglePlayServicesRepairableException import io.mockk.every import io.mockk.mockk -import mozilla.components.browser.search.SearchEngine -import mozilla.components.support.test.robolectric.testContext +import io.mockk.mockkObject +import io.mockk.mockkStatic +import io.mockk.slot +import kotlinx.coroutines.runBlocking +import org.junit.Assert import org.junit.Assert.assertEquals +import org.junit.Ignore import org.junit.Test -import org.junit.runner.RunWith -import org.mozilla.fenix.helpers.FenixRobolectricTestRunner +import org.mockito.ArgumentMatchers +import java.io.IOException -@RunWith(FenixRobolectricTestRunner::class) class MetricsUtilsTest { + private val context: Context = mockk(relaxed = true) + + @Ignore("This test has side-effects that cause it to fail other unrelated tests.") @Test - fun createSearchEvent() { - val engine: SearchEngine = mockk(relaxed = true) - val context = testContext + fun `getAdvertisingID() returns null if the API throws`() { + val exceptions = listOf( + GooglePlayServicesNotAvailableException(1), + GooglePlayServicesRepairableException(0, ArgumentMatchers.anyString(), ArgumentMatchers.any()), + IllegalStateException(), + IOException() + ) - every { engine.identifier } returns ENGINE_SOURCE_IDENTIFIER + exceptions.forEach { + every { + AdvertisingIdClient.getAdvertisingIdInfo(any()) + } throws it - assertEquals( - "$ENGINE_SOURCE_IDENTIFIER.suggestion", - MetricsUtils.createSearchEvent( - engine, - context, - Event.PerformedSearch.SearchAccessPoint.SUGGESTION - )?.eventSource?.countLabel - ) - assertEquals( - "$ENGINE_SOURCE_IDENTIFIER.action", - MetricsUtils.createSearchEvent( - engine, - context, - Event.PerformedSearch.SearchAccessPoint.ACTION - )?.eventSource?.countLabel - ) - assertEquals( - "$ENGINE_SOURCE_IDENTIFIER.widget", - MetricsUtils.createSearchEvent( - engine, - context, - Event.PerformedSearch.SearchAccessPoint.WIDGET - )?.eventSource?.countLabel - ) - assertEquals( - "$ENGINE_SOURCE_IDENTIFIER.shortcut", - MetricsUtils.createSearchEvent( - engine, - context, - Event.PerformedSearch.SearchAccessPoint.SHORTCUT - )?.eventSource?.countLabel - ) + Assert.assertNull(MetricsUtils.getAdvertisingID(context)) + } + } + + @Test + fun `getAdvertisingID() returns null if the API returns null info`() { + mockkStatic(AdvertisingIdClient::class) + every { AdvertisingIdClient.getAdvertisingIdInfo(any()) } returns null + + Assert.assertNull(MetricsUtils.getAdvertisingID(context)) + } + + @Test + fun `getAdvertisingID() returns a valid string if the API returns a valid ID`() { + val testId = "test-value-id" + + mockkStatic(AdvertisingIdClient::class) + every { + AdvertisingIdClient.getAdvertisingIdInfo(any()) + } returns AdvertisingIdClient.Info(testId, false) + + assertEquals(testId, MetricsUtils.getAdvertisingID(context)) + } + + @Test + fun `getHashedIdentifier() returns a hashed identifier`() { + val testId = "test-value-id" + val testPackageName = "org.mozilla-test.fenix" + val mockedHexReturn = "mocked-HEX" + + // Mock the Base64 to record the byte array that is passed in, + // which is the actual digest. We can't simply test the return value + // of |getHashedIdentifier| as these Android tests require us to mock + // Android-specific APIs. + mockkStatic(Base64::class) + val shaDigest = slot() + every { + Base64.encodeToString(capture(shaDigest), any()) + } returns mockedHexReturn + + // Get the hash identifier. + mockkObject(MetricsUtils) + every { MetricsUtils.getAdvertisingID(context) } returns testId + every { MetricsUtils.getHashingSalt() } returns testPackageName + runBlocking { + assertEquals(mockedHexReturn, MetricsUtils.getHashedIdentifier(context)) + } + + // Check that the digest of the identifier matches with what we expect. + // Please note that in the real world, Base64.encodeToString would encode + // this to something much shorter, which we'd send with the ping. + val expectedDigestBytes = + "[52, -79, -84, 79, 101, 22, -82, -44, -44, -14, 21, 15, 48, 88, -94, -74, -8, 25, -72, -120, -37, 108, 47, 16, 2, -37, 126, 41, 102, -92, 103, 24]" + assertEquals(expectedDigestBytes, shaDigest.captured.contentToString()) } companion object { diff --git a/app/src/test/java/org/mozilla/fenix/components/metrics/MetricsUtilsTestRoboelectric.kt b/app/src/test/java/org/mozilla/fenix/components/metrics/MetricsUtilsTestRoboelectric.kt new file mode 100644 index 000000000..059929f86 --- /dev/null +++ b/app/src/test/java/org/mozilla/fenix/components/metrics/MetricsUtilsTestRoboelectric.kt @@ -0,0 +1,63 @@ +/* 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 org.mozilla.fenix.components.metrics + +import io.mockk.every +import io.mockk.mockk +import mozilla.components.browser.search.SearchEngine +import mozilla.components.support.test.robolectric.testContext +import org.junit.Assert +import org.junit.Test +import org.junit.runner.RunWith +import org.mozilla.fenix.helpers.FenixRobolectricTestRunner + +/** + * Just the Roboelectric tests for MetricsUtil. Splitting these files out means our other tests will run more quickly. + * FenixRobolectricTestRunner also breaks our ability to use mockkStatic on Base64. + */ +@RunWith(FenixRobolectricTestRunner::class) +class MetricsUtilsTestRoboelectric { + + @Test + fun createSearchEvent() { + val context = testContext + val engine: SearchEngine = mockk(relaxed = true) + + every { engine.identifier } returns MetricsUtilsTest.ENGINE_SOURCE_IDENTIFIER + + Assert.assertEquals( + "${MetricsUtilsTest.ENGINE_SOURCE_IDENTIFIER}.suggestion", + MetricsUtils.createSearchEvent( + engine, + context, + Event.PerformedSearch.SearchAccessPoint.SUGGESTION + )?.eventSource?.countLabel + ) + Assert.assertEquals( + "${MetricsUtilsTest.ENGINE_SOURCE_IDENTIFIER}.action", + MetricsUtils.createSearchEvent( + engine, + context, + Event.PerformedSearch.SearchAccessPoint.ACTION + )?.eventSource?.countLabel + ) + Assert.assertEquals( + "${MetricsUtilsTest.ENGINE_SOURCE_IDENTIFIER}.widget", + MetricsUtils.createSearchEvent( + engine, + context, + Event.PerformedSearch.SearchAccessPoint.WIDGET + )?.eventSource?.countLabel + ) + Assert.assertEquals( + "${MetricsUtilsTest.ENGINE_SOURCE_IDENTIFIER}.shortcut", + MetricsUtils.createSearchEvent( + engine, + context, + Event.PerformedSearch.SearchAccessPoint.SHORTCUT + )?.eventSource?.countLabel + ) + } +} diff --git a/docs/metrics.md b/docs/metrics.md index 5a0da0dcf..5319bcdd7 100644 --- a/docs/metrics.md +++ b/docs/metrics.md @@ -223,6 +223,7 @@ The following metrics are added to the ping: | installation.adgroup |[string](https://mozilla.github.io/glean/book/user/metrics/string.html) |The name of the AdGroup that was used to source this installation. |[1](https://github.com/mozilla-mobile/fenix/pull/8074#issuecomment-586480836)||2020-09-01 | | installation.campaign |[string](https://mozilla.github.io/glean/book/user/metrics/string.html) |The name of the campaign that is responsible for this installation. |[1](https://github.com/mozilla-mobile/fenix/pull/8074#issuecomment-586512202)||2020-09-01 | | installation.creative |[string](https://mozilla.github.io/glean/book/user/metrics/string.html) |The identifier of the creative material that the user interacted with. |[1](https://github.com/mozilla-mobile/fenix/pull/8074#issuecomment-586512202)||2020-09-01 | +| installation.identifier |[string](https://mozilla.github.io/glean/book/user/metrics/string.html) |The hashed and salted GAID. Used for a short term installation validation test. |[1](https://github.com/mozilla-mobile/fenix/pull/10446#issuecomment-624816258)||2020-05-10 | | installation.network |[string](https://mozilla.github.io/glean/book/user/metrics/string.html) |The name of the Network that sourced this installation. |[1](https://github.com/mozilla-mobile/fenix/pull/8074#issuecomment-586512202)||2020-09-01 | | installation.timestamp |[datetime](https://mozilla.github.io/glean/book/user/metrics/datetime.html) |The date and time of the installation. |[1](https://github.com/mozilla-mobile/fenix/pull/8074#issuecomment-586512202)||2020-09-01 |