Bug 1860133 - Report Meta attribution from the Play Store referrer

fenix/121.0
Roger Yang 8 months ago committed by mergify[bot]
parent cc879baeba
commit 04e63467c4

@ -7611,6 +7611,80 @@ play_store_attribution:
tags:
- Attribution
- Performance
meta_attribution:
app:
type: string
send_in_pings:
- first-session
description: |
The mobile application ID in Meta's attribution.
bugs:
- https://bugzilla.mozilla.org/show_bug.cgi?id=1860133
data_reviews:
- https://github.com/mozilla-mobile/firefox-android/pull/4171
data_sensitivity:
- technical
notification_emails:
- android-probes@mozilla.com
expires: never
metadata:
tags:
- Attribution
t:
type: string
send_in_pings:
- first-session
description: |
Value tracking user interaction with Meta attribution.
bugs:
- https://bugzilla.mozilla.org/show_bug.cgi?id=1860133
data_reviews:
- https://github.com/mozilla-mobile/firefox-android/pull/4171
data_sensitivity:
- technical
notification_emails:
- android-probes@mozilla.com
expires: never
metadata:
tags:
- Attribution
data:
type: text
send_in_pings:
- first-session
description: |
The Meta attribution data in encrypted format.
bugs:
- https://bugzilla.mozilla.org/show_bug.cgi?id=1860133
data_reviews:
- https://github.com/mozilla-mobile/firefox-android/pull/4171
data_sensitivity:
# - technical
- web_activity # This is a workaround so we can use Text type for technical data.
notification_emails:
- android-probes@mozilla.com
expires: never
metadata:
tags:
- Attribution
nonce:
type: string
send_in_pings:
- first-session
description: |
Nonce used to decrypt the encrypted Meta attribution data.
bugs:
- https://bugzilla.mozilla.org/show_bug.cgi?id=1860133
data_reviews:
- https://github.com/mozilla-mobile/firefox-android/pull/4171
data_sensitivity:
- technical
notification_emails:
- android-probes@mozilla.com
expires: never
metadata:
tags:
- Attribution
browser.search:
with_ads:
type: labeled_counter

@ -73,4 +73,9 @@ object FeatureFlags {
* Allows users to enable Firefox Suggest.
*/
const val fxSuggest = true
/**
* Enable Meta attribution.
*/
val metaAttributionEnabled = Config.channel.isNightlyOrDebug
}

@ -5,22 +5,26 @@
package org.mozilla.fenix.components.metrics
import android.content.Context
import android.net.UrlQuerySanitizer
import android.os.RemoteException
import androidx.annotation.VisibleForTesting
import com.android.installreferrer.api.InstallReferrerClient
import com.android.installreferrer.api.InstallReferrerStateListener
import mozilla.components.support.base.log.logger.Logger
import org.json.JSONException
import org.json.JSONObject
import org.mozilla.fenix.FeatureFlags
import org.mozilla.fenix.GleanMetrics.MetaAttribution
import org.mozilla.fenix.GleanMetrics.PlayStoreAttribution
import org.mozilla.fenix.ext.settings
import org.mozilla.fenix.utils.Settings
import java.net.URLDecoder
/**
* A metrics service used to derive the UTM parameters with the Google Play Install Referrer library.
*
* At first startup, the [UTMParams] are derived from the install referrer URL and stored in settings.
* At first startup, the [UTMParams] and/or [MetaParams] are derived from the install referrer URL
* and stored in settings.
*/
class InstallReferrerMetricsService(private val context: Context) : MetricsService {
private val logger = Logger("InstallReferrerMetricsService")
override val type = MetricServiceType.Marketing
private var referrerClient: InstallReferrerClient? = null
@ -41,14 +45,26 @@ class InstallReferrerMetricsService(private val context: Context) : MetricsServi
when (responseCode) {
InstallReferrerClient.InstallReferrerResponse.OK -> {
// Connection established.
try {
val response = client.installReferrer
recordInstallReferrer(context.settings(), response.installReferrer)
context.settings().utmParamsKnown = true
val installReferrerResponse = try {
client.installReferrer.installReferrer
} catch (e: RemoteException) {
// NOOP.
// We can't do anything about this.
logger.error("Failed to retrieve install referrer response", e)
null
}
if (installReferrerResponse.isNullOrBlank()) {
return
}
val utmParams = UTMParams.parseUTMParameters(installReferrerResponse)
if (FeatureFlags.metaAttributionEnabled) {
MetaParams.extractMetaAttribution(utmParams.content)
?.recordMetaAttribution()
}
utmParams.recordInstallReferrer(context.settings())
context.settings().utmParamsKnown = true
}
InstallReferrerClient.InstallReferrerResponse.FEATURE_NOT_SUPPORTED -> {
@ -81,36 +97,6 @@ class InstallReferrerMetricsService(private val context: Context) : MetricsServi
override fun track(event: Event) = Unit
override fun shouldTrack(event: Event): Boolean = false
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
internal fun recordInstallReferrer(settings: Settings, url: String?) {
if (url.isNullOrBlank()) {
return
}
val params = UTMParams.fromURLString(url)
if (params == null || params.isEmpty()) {
return
}
params.intoSettings(settings)
params.apply {
source?.let {
PlayStoreAttribution.source.set(it)
}
medium?.let {
PlayStoreAttribution.medium.set(it)
}
campaign?.let {
PlayStoreAttribution.campaign.set(it)
}
content?.let {
PlayStoreAttribution.content.set(it)
}
term?.let {
PlayStoreAttribution.term.set(it)
}
}
}
}
/**
@ -134,61 +120,44 @@ class InstallReferrerMetricsService(private val context: Context) : MetricsServi
* which version is more effective.
*/
data class UTMParams(
val source: String?,
val medium: String?,
val campaign: String?,
val term: String?,
val content: String?,
val source: String,
val medium: String,
val campaign: String,
val content: String,
val term: String,
) {
companion object {
const val UTM_SOURCE = "utm_source"
const val UTM_MEDIUM = "utm_medium"
const val UTM_CAMPAIGN = "utm_campaign"
const val UTM_TERM = "utm_term"
const val UTM_CONTENT = "utm_content"
const val UTM_TERM = "utm_term"
/**
* Try and unpack the referrer URL by successively URLDecoding the URL.
*
* Once the url ceases to decode anymore, it gives up.
*/
fun fromURLString(urlString: String): UTMParams? {
// Look for the first time 'utm_' is detected, after the first '?'.
val utmIndex = urlString.indexOf("utm_", urlString.indexOf('?'))
if (utmIndex < 0) {
return null
}
var url = urlString.substring(utmIndex)
while (true) {
val params = fromQueryString(url)
if (!params.isEmpty()) {
return params
}
val newValue = URLDecoder.decode(url, "UTF-8")
if (newValue == url) {
break
fun parseUTMParameters(referrerUrl: String): UTMParams {
val utmParams = mutableMapOf<String, String>()
val params = referrerUrl.split("&")
for (param in params) {
val keyValue = param.split("=")
if (keyValue.size == 2) {
val key = keyValue[0]
val value = keyValue[1]
utmParams[key] = value
}
url = newValue
}
return null
}
/**
* Derive a set of UTM parameters from a string URL.
*/
fun fromQueryString(queryString: String): UTMParams =
with(UrlQuerySanitizer()) {
allowUnregisteredParamaters = true
unregisteredParameterValueSanitizer = UrlQuerySanitizer.getUrlAndSpaceLegal()
parseQuery(queryString)
UTMParams(
source = getValue(UTM_SOURCE),
medium = getValue(UTM_MEDIUM),
campaign = getValue(UTM_CAMPAIGN),
term = getValue(UTM_TERM),
content = getValue(UTM_CONTENT),
)
}
return UTMParams(
source = utmParams[UTM_SOURCE] ?: "",
medium = utmParams[UTM_MEDIUM] ?: "",
campaign = utmParams[UTM_CAMPAIGN] ?: "",
content = utmParams[UTM_CONTENT] ?: "",
term = utmParams[UTM_TERM] ?: "",
)
}
/**
* Derive the set of UTM parameters stored in Settings.
@ -199,8 +168,8 @@ data class UTMParams(
source = utmSource,
medium = utmMedium,
campaign = utmCampaign,
term = utmTerm,
content = utmContent,
term = utmTerm,
)
}
}
@ -210,22 +179,132 @@ data class UTMParams(
*/
fun intoSettings(settings: Settings) {
with(settings) {
utmSource = source ?: ""
utmMedium = medium ?: ""
utmCampaign = campaign ?: ""
utmTerm = term ?: ""
utmContent = content ?: ""
utmSource = source
utmMedium = medium
utmCampaign = campaign
utmTerm = term
utmContent = content
}
}
/**
* Return [true] if none of the utm params are set.
* Check if this UTM param is empty
*
* @Return [Boolean] true if none of the utm params are set.
*/
fun isEmpty(): Boolean {
return source.isNullOrBlank() &&
medium.isNullOrBlank() &&
campaign.isNullOrBlank() &&
term.isNullOrBlank() &&
content.isNullOrBlank()
return source.isBlank() &&
medium.isBlank() &&
campaign.isBlank() &&
term.isBlank() &&
content.isBlank()
}
/**
* record UTM params into settings and telemetry
*
* @param settings [Settings] application settings.
*/
fun recordInstallReferrer(settings: Settings) {
if (isEmpty()) {
return
}
intoSettings(settings)
PlayStoreAttribution.source.set(source)
PlayStoreAttribution.medium.set(medium)
PlayStoreAttribution.campaign.set(campaign)
PlayStoreAttribution.content.set(content)
PlayStoreAttribution.term.set(term)
}
}
/**
* Descriptions of Meta attribution parameters comes from
* https://developers.facebook.com/docs/marketing-api/reference/ad-campaign#fields
*
* @property app the ID of application in the referrer response.
* @property t the value of user interaction in the referrer response.
* @property data the encrypted data in the referrer response.
* @property nonce the nonce for decrypting [data] in the referrer response.
*/
data class MetaParams(
val app: String,
val t: String,
val data: String,
val nonce: String,
) {
companion object {
private val logger = Logger("MetaParams")
private const val APP = "app"
private const val T = "t"
private const val SOURCE = "source"
private const val DATA = "data"
private const val NONCE = "nonce"
@Suppress("ReturnCount")
internal fun extractMetaAttribution(contentString: String?): MetaParams? {
if (contentString == null) {
return null
}
val data: String
val nonce: String
val contentJson = try {
JSONObject(contentString)
} catch (e: JSONException) {
logger.error("content is not JSON", e)
// can't recover from this
return null
}
val app = try {
contentJson.optString(APP) ?: ""
} catch (e: JSONException) {
logger.error("failed to extract app", e)
// this is an acceptable outcome
""
}
val t = try {
contentJson.optString(T) ?: ""
} catch (e: JSONException) {
logger.error("failed to extract t", e)
// this is an acceptable outcome
""
}
try {
val source = contentJson.optJSONObject(SOURCE)
data = source?.optString(DATA) ?: ""
nonce = source?.optString(NONCE) ?: ""
} catch (e: JSONException) {
logger.error("failed to extract data or nonce", e)
// can't recover from this
return null
}
if (data.isBlank() || nonce.isBlank()) {
return null
}
return MetaParams(
app = app,
t = t,
data = data,
nonce = nonce,
)
}
}
/**
* record META attribution params to telemetry
*/
fun recordMetaAttribution() {
MetaAttribution.app.set(app)
MetaAttribution.t.set(t)
MetaAttribution.data.set(data)
MetaAttribution.nonce.set(nonce)
}
}

@ -10,12 +10,12 @@ import mozilla.components.service.glean.testing.GleanTestRule
import mozilla.components.support.test.robolectric.testContext
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.mozilla.fenix.GleanMetrics.MetaAttribution
import org.mozilla.fenix.GleanMetrics.PlayStoreAttribution
import org.mozilla.fenix.utils.Settings
import org.robolectric.RobolectricTestRunner
@ -28,29 +28,7 @@ internal class InstallReferrerMetricsServiceTest {
val gleanTestRule = GleanTestRule(testContext)
@Test
fun testUtmParamsFromUrl() {
assertEquals("SOURCE", UTMParams.fromQueryString("utm_source=SOURCE").source)
assertEquals("MEDIUM", UTMParams.fromQueryString("utm_medium=MEDIUM").medium)
assertEquals("CAMPAIGN", UTMParams.fromQueryString("utm_campaign=CAMPAIGN").campaign)
assertEquals("TERM", UTMParams.fromQueryString("utm_term=TERM").term)
assertEquals("CONTENT", UTMParams.fromQueryString("utm_content=CONTENT").content)
}
@Test
fun testUtmParamsFromUrlWithSpaces() {
assertEquals("WITH SPACE", UTMParams.fromQueryString("utm_source=WITH+SPACE").source)
assertEquals("WITH SPACE", UTMParams.fromQueryString("utm_medium=WITH%20SPACE").medium)
assertEquals("WITH SPACE", UTMParams.fromQueryString("utm_campaign=WITH SPACE").campaign)
}
@Test
fun testUtmParamsFromUrlWithMissingParams() {
assertNull(UTMParams.fromQueryString("missing=").source)
assertEquals("", UTMParams.fromQueryString("utm_source=").source)
}
@Test
fun testUtmParamsRoundTripThroughSettingsMinimumParams() {
fun `WHEN retrieving minimum UTM params from setting THEN result should match`() {
val settings = Settings(context)
val expected = UTMParams(source = "", medium = "", campaign = "", content = "", term = "")
val observed = UTMParams.fromSettings(settings)
@ -60,7 +38,7 @@ internal class InstallReferrerMetricsServiceTest {
}
@Test
fun testUtmParamsRoundTripThroughSettingsMaximumParams() {
fun `WHEN retrieving maximum UTM params from setting THEN result should match`() {
val expected = UTMParams(source = "source", medium = "medium", campaign = "campaign", content = "content", term = "term")
val settings = Settings(context)
@ -73,10 +51,10 @@ internal class InstallReferrerMetricsServiceTest {
}
@Test
fun testInstallReferrerMetricsMinimumParams() {
val service = InstallReferrerMetricsService(context)
fun `WHEN parsing referrer response with no UTM params from setting THEN UTM params in settings should set to empty strings`() {
val settings = Settings(context)
service.recordInstallReferrer(settings, "https://example.com")
val params = UTMParams.parseUTMParameters("")
params.recordInstallReferrer(settings)
val expected = UTMParams(source = "", medium = "", campaign = "", content = "", term = "")
val observed = UTMParams.fromSettings(settings)
@ -92,29 +70,29 @@ internal class InstallReferrerMetricsServiceTest {
}
@Test
fun testInstallReferrerMetricsPartial() {
val service = InstallReferrerMetricsService(context)
fun `WHEN parsing referrer response with partial UTM params from setting THEN UTM params in settings should match expected`() {
val settings = Settings(context)
service.recordInstallReferrer(settings, "https://example.com?utm_campaign=CAMPAIGN")
val params = UTMParams.parseUTMParameters("utm_campaign=CAMPAIGN")
params.recordInstallReferrer(settings)
val expected = UTMParams(source = "", medium = "", campaign = "CAMPAIGN", content = "", term = "")
val observed = UTMParams.fromSettings(settings)
assertEquals(observed, expected)
assertNull(PlayStoreAttribution.source.testGetValue())
assertNull(PlayStoreAttribution.medium.testGetValue())
assertEquals("", PlayStoreAttribution.source.testGetValue())
assertEquals("", PlayStoreAttribution.medium.testGetValue())
assertEquals("CAMPAIGN", PlayStoreAttribution.campaign.testGetValue())
assertNull(PlayStoreAttribution.content.testGetValue())
assertNull(PlayStoreAttribution.term.testGetValue())
assertEquals("", PlayStoreAttribution.content.testGetValue())
assertEquals("", PlayStoreAttribution.term.testGetValue())
assertFalse(observed.isEmpty())
}
@Test
fun testInstallReferrerMetricsMaximumParams() {
val service = InstallReferrerMetricsService(context)
fun `WHEN parsing referrer response with full UTM params from setting THEN UTM params in settings should match expected`() {
val settings = Settings(context)
service.recordInstallReferrer(settings, "https://example.com?utm_source=SOURCE&utm_medium=MEDIUM&utm_campaign=CAMPAIGN&utm_content=CONTENT&utm_term=TERM")
val params = UTMParams.parseUTMParameters("utm_source=SOURCE&utm_medium=MEDIUM&utm_campaign=CAMPAIGN&utm_content=CONTENT&utm_term=TERM")
params.recordInstallReferrer(settings)
val expected = UTMParams(source = "SOURCE", medium = "MEDIUM", campaign = "CAMPAIGN", content = "CONTENT", term = "TERM")
val observed = UTMParams.fromSettings(settings)
@ -130,53 +108,66 @@ internal class InstallReferrerMetricsServiceTest {
}
@Test
fun testInstallReferrerMetricsShouldTrack() {
fun `WHEN Install referrer metrics service should track is called THEN it should always return false`() {
val service = InstallReferrerMetricsService(context)
assertFalse(service.shouldTrack(Event.GrowthData.FirstAppOpenForDay))
}
@Test
fun testInstallReferrerMetricsType() {
fun `WHEN Install referrer metrics service starts THEN then the service type should be marketing`() {
val service = InstallReferrerMetricsService(context)
assertEquals(MetricServiceType.Marketing, service.type)
}
@Test
fun testDecodeReferrerUrl() {
// Example from https://developers.google.com/analytics/devguides/collection/android/v4/campaigns#google-play-url-builder
val params = UTMParams.fromURLString(
"https://play.google.com/store/apps/details?id=com.example.application" +
"&referrer=utm_source%3Dgoogle" +
"%26utm_medium%3Dcpc" +
"%26utm_term%3Drunning%252Bshoes" +
"%26utm_content%3Dlogolink" +
"%26utm_campaign%3Dspring_sale",
)
assertNotNull(params)
val expected = UTMParams(
source = "google",
medium = "cpc",
campaign = "spring_sale",
content = "logolink",
term = "running+shoes",
)
fun `WHEN receiving a Meta encrypted attribution THEN will decrypt correctly`() {
val metaParams = MetaParams.extractMetaAttribution("""{"app":12345, "t":1234567890,"source":{"data":"DATA","nonce":"NONCE"}}""")
val expectedMetaParams = MetaParams("12345", "1234567890", "DATA", "NONCE")
assertEquals(expected, params)
assertEquals(metaParams, expectedMetaParams)
}
@Test
fun testDecodeReferrerAdMobUrl() {
val expected = UTMParams(source = "SOURCE", medium = "MEDIUM", campaign = "CAMPAIGN", content = "CONTENT", term = "TERM")
// Generated with https://developers.google.com/analytics/devguides/collection/android/v4/campaigns#google-play-url-builder
val fromUrl = UTMParams.fromURLString(
"https://play.google.com/store/apps/details?id=org.mozilla.fenix&referrer=utm_source%3DSOURCE%26utm_medium%3DMEDIUM%26utm_term%3DTERM%26utm_content%3DCONTENT%26utm_campaign%3DCAMPAIGN%26anid%3Dadmob",
fun `WHEN parsing referrer response with meta attribution THEN both UTM and Meta params should match expected`() {
val utmParams = UTMParams.parseUTMParameters("""utm_content={"app":12345, "t":1234567890,"source":{"data":"DATA","nonce":"NONCE"}}""")
val expectedUtmParams = UTMParams(source = "", medium = "", campaign = "", content = """{"app":12345, "t":1234567890,"source":{"data":"DATA","nonce":"NONCE"}}""", term = "")
assertEquals(utmParams, expectedUtmParams)
val metaParams = MetaParams.extractMetaAttribution(utmParams.content)
val expectedMetaParams = MetaParams("12345", "1234567890", "DATA", "NONCE")
assertEquals(metaParams, expectedMetaParams)
}
@Test
fun `WHEN recording Meta attribution THEN correct values should be recorded to telemetry`() {
// The data and nonce are from Meta's example https://developers.facebook.com/docs/app-ads/install-referrer/
val metaParams = MetaParams(
"12345",
"1234567890",
"afe56cf6228c6ea8c79da49186e718e92a579824596ae1d0d4d20d7793dca797bd4034ccf467bfae5c79a3981e7a2968c41949237e2b2db678c1c3d39c9ae564c5cafd52f2b77a3dc77bf1bae063114d0283b97417487207735da31ddc1531d5645a9c3e602c195a0ebf69c272aa5fda3a2d781cb47e117310164715a54c7a5a032740584e2789a7b4e596034c16425139a77e507c492b629c848573c714a03a2e7d25b9459b95842332b460f3682d19c35dbc7d53e3a51e0497ff6a6cbb367e760debc4194ae097498108df7b95eac2fa9bac4320077b510be3b7b823248bfe02ae501d9fe4ba179c7de6733c92bf89d523df9e31238ef497b9db719484cbab7531dbf6c5ea5a8087f95d59f5e4f89050e0f1dc03e464168ad76a64cca64b79",
"b7203c6a6fb633d16e9cf5c1",
)
assertNotNull(fromUrl)
assertEquals(expected, fromUrl)
val fromReferrerAttribute = UTMParams.fromURLString("utm_source%3DSOURCE%26utm_medium%3DMEDIUM%26utm_term%3DTERM%26utm_content%3DCONTENT%26utm_campaign%3DCAMPAIGN%26anid%3Dadmob")
assertNotNull(fromReferrerAttribute)
assertEquals(expected, fromReferrerAttribute)
assertNull(MetaAttribution.app.testGetValue())
assertNull(MetaAttribution.t.testGetValue())
assertNull(MetaAttribution.data.testGetValue())
assertNull(MetaAttribution.nonce.testGetValue())
metaParams.recordMetaAttribution()
val expectedApp = "12345"
val expectedT = "1234567890"
val expectedData = "afe56cf6228c6ea8c79da49186e718e92a579824596ae1d0d4d20d7793dca797bd4034ccf467bfae5c79a3981e7a2968c41949237e2b2db678c1c3d39c9ae564c5cafd52f2b77a3dc77bf1bae063114d0283b97417487207735da31ddc1531d5645a9c3e602c195a0ebf69c272aa5fda3a2d781cb47e117310164715a54c7a5a032740584e2789a7b4e596034c16425139a77e507c492b629c848573c714a03a2e7d25b9459b95842332b460f3682d19c35dbc7d53e3a51e0497ff6a6cbb367e760debc4194ae097498108df7b95eac2fa9bac4320077b510be3b7b823248bfe02ae501d9fe4ba179c7de6733c92bf89d523df9e31238ef497b9db719484cbab7531dbf6c5ea5a8087f95d59f5e4f89050e0f1dc03e464168ad76a64cca64b79"
val expectedNonce = "b7203c6a6fb633d16e9cf5c1"
val recordedApp = MetaAttribution.app.testGetValue()
assertEquals(recordedApp, expectedApp)
val recordedT = MetaAttribution.t.testGetValue()
assertEquals(recordedT, expectedT)
val recordedData = MetaAttribution.data.testGetValue()
assertEquals(recordedData, expectedData)
val recordedNonce = MetaAttribution.nonce.testGetValue()
assertEquals(recordedNonce, expectedNonce)
}
}

Loading…
Cancel
Save