Commit c852301c authored by Christian Sadilek's avatar Christian Sadilek Committed by ekager
Browse files

Closes #11286: Add TelemetryMiddleware to remove Session[Manager] observers

parent 412b412d
......@@ -38,7 +38,6 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import mozilla.components.browser.search.SearchEngine
import mozilla.components.browser.session.SessionManager
import mozilla.components.browser.state.selector.getNormalOrPrivateTabs
import mozilla.components.browser.state.state.SessionState
import mozilla.components.browser.state.state.WebExtensionState
......@@ -62,7 +61,6 @@ import mozilla.components.support.webextensions.WebExtensionPopupFeature
import org.mozilla.fenix.GleanMetrics.Metrics
import org.mozilla.fenix.addons.AddonDetailsFragmentDirections
import org.mozilla.fenix.addons.AddonPermissionsDetailsFragmentDirections
import org.mozilla.fenix.browser.UriOpenedObserver
import org.mozilla.fenix.browser.browsingmode.BrowsingMode
import org.mozilla.fenix.browser.browsingmode.BrowsingModeManager
import org.mozilla.fenix.browser.browsingmode.DefaultBrowsingModeManager
......@@ -123,7 +121,6 @@ open class HomeActivity : LocaleAwareAppCompatActivity(), NavHostActivity {
private var webExtScope: CoroutineScope? = null
lateinit var themeManager: ThemeManager
lateinit var browsingModeManager: BrowsingModeManager
private lateinit var sessionObserver: SessionManager.Observer
private var isVisuallyComplete = false
......@@ -183,8 +180,6 @@ open class HomeActivity : LocaleAwareAppCompatActivity(), NavHostActivity {
.attachViewToRunVisualCompletenessQueueLater(WeakReference(rootContainer))
}
sessionObserver = UriOpenedObserver(this)
checkPrivateShortcutEntryPoint(intent)
privateNotificationObserver = PrivateNotificationFeature(
applicationContext,
......
/* 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
import androidx.annotation.VisibleForTesting
import mozilla.components.browser.state.action.BrowserAction
import mozilla.components.browser.state.action.ContentAction
import mozilla.components.browser.state.action.TabListAction
import mozilla.components.browser.state.selector.findTab
import mozilla.components.browser.state.selector.normalTabs
import mozilla.components.browser.state.state.BrowserState
import mozilla.components.lib.state.Middleware
import mozilla.components.lib.state.MiddlewareContext
import mozilla.components.support.base.log.logger.Logger
import org.mozilla.fenix.components.metrics.Event
import org.mozilla.fenix.components.metrics.MetricController
import org.mozilla.fenix.search.telemetry.ads.AdsTelemetry
import org.mozilla.fenix.utils.Settings
/**
* [Middleware] to record telemetry in response to [BrowserAction]s.
*
* @property settings reference to the application [Settings].
* @property adsTelemetry reference to [AdsTelemetry] use to record search telemetry.
* @property metrics reference to the configured [MetricController] to record general page load events.
*/
class TelemetryMiddleware(
private val settings: Settings,
private val adsTelemetry: AdsTelemetry,
private val metrics: MetricController
) : Middleware<BrowserState, BrowserAction> {
private val logger = Logger("TelemetryMiddleware")
@VisibleForTesting
internal val redirectChains = mutableMapOf<String, RedirectChain>()
/**
* Utility to collect URLs / load requests in between location changes.
*/
internal class RedirectChain(internal val root: String) {
internal val chain = mutableListOf<String>()
fun add(url: String) {
chain.add(url)
}
}
@Suppress("TooGenericExceptionCaught")
override fun invoke(
context: MiddlewareContext<BrowserState, BrowserAction>,
next: (BrowserAction) -> Unit,
action: BrowserAction
) {
// Pre process actions
when (action) {
is ContentAction.UpdateLoadingStateAction -> {
context.state.findTab(action.sessionId)?.let { tab ->
// Record UriOpened event when a non-private page finishes loading
if (tab.content.loading && !action.loading && !tab.content.private) {
metrics.track(Event.UriOpened)
}
}
}
is ContentAction.UpdateLoadRequestAction -> {
context.state.findTab(action.sessionId)?.let { tab ->
// Collect all load requests in between location changes
if (!redirectChains.containsKey(action.sessionId) && action.loadRequest.url != tab.content.url) {
redirectChains[action.sessionId] = RedirectChain(tab.content.url)
}
redirectChains[action.sessionId]?.add(action.loadRequest.url)
}
}
is ContentAction.UpdateUrlAction -> {
redirectChains[action.sessionId]?.let {
// Record ads telemetry providing all redirects
try {
adsTelemetry.trackAdClickedMetric(it.root, it.chain)
} catch (t: Throwable) {
logger.info("Failed to record search telemetry", t)
} finally {
redirectChains.remove(action.sessionId)
}
}
}
}
next(action)
// Post process actions
when (action) {
is TabListAction.AddTabAction,
is TabListAction.AddMultipleTabsAction,
is TabListAction.RemoveTabAction,
is TabListAction.RemoveAllNormalTabsAction,
is TabListAction.RemoveAllTabsAction,
is TabListAction.RestoreAction -> {
// Update/Persist tabs count whenever it changes
settings.openTabsCount = context.state.normalTabs.count()
}
}
}
}
/* 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.browser
import androidx.annotation.VisibleForTesting
import mozilla.components.browser.session.Session
import org.mozilla.fenix.components.metrics.Event
import org.mozilla.fenix.components.metrics.MetricController
import org.mozilla.fenix.search.telemetry.ads.AdsTelemetry
class TelemetrySessionObserver(
private val metrics: MetricController,
private val ads: AdsTelemetry
) : Session.Observer {
private var urlLoading: String? = null
@VisibleForTesting
var redirectChain = mutableListOf<String>()
@VisibleForTesting
var originSessionUrl: String? = null
private val temporaryFix = TemporaryFix()
override fun onLoadingStateChanged(session: Session, loading: Boolean) {
if (loading) {
urlLoading = session.url
} else if (urlLoading != null && !session.private && temporaryFix.shouldSendEvent(session.url)) {
temporaryFix.eventSentFor = session.url
metrics.track(Event.UriOpened)
}
}
/**
* When a link is clicked, record its redirect chain as well as origin url
*/
override fun onLoadRequest(
session: Session,
url: String,
triggeredByRedirect: Boolean,
triggeredByWebContent: Boolean
) {
if (isFirstLinkInRedirectChain(url, session.url)) {
originSessionUrl = session.url
}
if (canStartChain()) {
redirectChain.add(url)
}
}
private fun canStartChain(): Boolean {
return originSessionUrl != null
}
private fun isFirstLinkInRedirectChain(url: String, sessionUrl: String): Boolean {
return originSessionUrl == null && url != sessionUrl
}
/**
* After the redirect chain has finished, check if we encountered an ad on the way and clear
* the stored info for that chain
*/
override fun onUrlChanged(session: Session, url: String) {
ads.trackAdClickedMetric(originSessionUrl, redirectChain)
originSessionUrl = null
redirectChain.clear()
}
/**
* Currently, [Session.Observer.onLoadingStateChanged] is called multiple times the first
* time a new session loads a page. This is inflating our telemetry numbers, so we need to
* handle it, but we will be able to remove this code when [onLoadingStateChanged] has
* been fixed.
*
* See Fenix #3676
* See AC https://github.com/mozilla-mobile/android-components/issues/4795
* TODO remove this class after AC #4795 has been fixed
*/
private class TemporaryFix {
var eventSentFor: String? = null
fun shouldSendEvent(newUrl: String): Boolean = eventSentFor != newUrl
}
}
/* 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.browser
import androidx.annotation.VisibleForTesting
import androidx.fragment.app.FragmentActivity
import androidx.lifecycle.LifecycleOwner
import mozilla.components.browser.session.Session
import mozilla.components.browser.session.SessionManager
import org.mozilla.fenix.components.metrics.MetricController
import org.mozilla.fenix.ext.components
import org.mozilla.fenix.ext.metrics
import org.mozilla.fenix.ext.sessionsOfType
import org.mozilla.fenix.ext.settings
import org.mozilla.fenix.search.telemetry.ads.AdsTelemetry
import org.mozilla.fenix.utils.Settings
class UriOpenedObserver(
private val settings: Settings,
private val owner: LifecycleOwner,
private val sessionManager: SessionManager,
metrics: MetricController,
ads: AdsTelemetry
) : SessionManager.Observer {
constructor(activity: FragmentActivity) : this(
activity.applicationContext.settings(),
activity,
activity.components.core.sessionManager,
activity.metrics,
activity.components.core.adsTelemetry
)
@VisibleForTesting
internal val singleSessionObserver = TelemetrySessionObserver(metrics, ads)
init {
sessionManager.register(this, owner)
sessionManager.selectedSession?.register(singleSessionObserver, owner)
}
override fun onSessionSelected(session: Session) {
session.register(singleSessionObserver, owner)
}
private fun saveOpenTabsCount() {
settings.openTabsCount = sessionManager.sessionsOfType(private = false).count()
}
override fun onAllSessionsRemoved() {
saveOpenTabsCount()
sessionManager.sessions.forEach {
it.unregister(singleSessionObserver)
}
}
override fun onSessionAdded(session: Session) {
saveOpenTabsCount()
session.register(singleSessionObserver, owner)
}
override fun onSessionRemoved(session: Session) {
saveOpenTabsCount()
session.unregister(singleSessionObserver)
}
override fun onSessionsRestored() {
saveOpenTabsCount()
sessionManager.sessions.forEach {
it.register(singleSessionObserver, owner)
}
}
}
......@@ -62,6 +62,7 @@ import org.mozilla.fenix.Config
import org.mozilla.fenix.HomeActivity
import org.mozilla.fenix.R
import org.mozilla.fenix.StrictModeManager
import org.mozilla.fenix.TelemetryMiddleware
import org.mozilla.fenix.downloads.DownloadService
import org.mozilla.fenix.ext.components
import org.mozilla.fenix.ext.settings
......@@ -154,6 +155,11 @@ class Core(
MediaMiddleware(context, MediaService::class.java),
DownloadMiddleware(context, DownloadService::class.java),
ReaderViewMiddleware(),
TelemetryMiddleware(
context.settings(),
adsTelemetry,
metrics
),
ThumbnailsMiddleware(thumbnailStorage),
UndoMiddleware(::lookupSessionManager, context.getUndoDelay())
) + EngineMiddleware.create(engine, ::findSessionById)
......@@ -250,12 +256,16 @@ class Core(
BrowserIcons(context, client)
}
val metrics by lazy {
context.components.analytics.metrics
}
val adsTelemetry by lazy {
AdsTelemetry(context.components.analytics.metrics)
AdsTelemetry(metrics)
}
val searchTelemetry by lazy {
InContentTelemetry(context.components.analytics.metrics)
InContentTelemetry(metrics)
}
/**
......
/* 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
import io.mockk.mockk
import io.mockk.verify
import mozilla.components.browser.state.action.ContentAction
import mozilla.components.browser.state.action.TabListAction
import mozilla.components.browser.state.state.LoadRequestState
import mozilla.components.browser.state.state.createTab
import mozilla.components.browser.state.store.BrowserStore
import mozilla.components.support.test.ext.joinBlocking
import mozilla.components.support.test.robolectric.testContext
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.mozilla.fenix.components.metrics.Event
import org.mozilla.fenix.components.metrics.MetricController
import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
import org.mozilla.fenix.search.telemetry.ads.AdsTelemetry
import org.mozilla.fenix.utils.Settings
@RunWith(FenixRobolectricTestRunner::class)
class TelemetryMiddlewareTest {
private lateinit var store: BrowserStore
private lateinit var settings: Settings
private lateinit var telemetryMiddleware: TelemetryMiddleware
private lateinit var metrics: MetricController
private lateinit var adsTelemetry: AdsTelemetry
@Before
fun setUp() {
settings = Settings(testContext)
metrics = mockk()
adsTelemetry = mockk()
telemetryMiddleware = TelemetryMiddleware(
settings,
adsTelemetry,
metrics
)
store = BrowserStore(middleware = listOf(telemetryMiddleware))
}
@Test
fun `WHEN a tab is added THEN the open tab count is updated`() {
assertEquals(0, settings.openTabsCount)
store.dispatch(TabListAction.AddTabAction(createTab("https://mozilla.org"))).joinBlocking()
assertEquals(1, settings.openTabsCount)
}
@Test
fun `WHEN a private tab is added THEN the open tab count is not updated`() {
assertEquals(0, settings.openTabsCount)
store.dispatch(TabListAction.AddTabAction(createTab("https://mozilla.org", private = true))).joinBlocking()
assertEquals(0, settings.openTabsCount)
}
@Test
fun `WHEN multiple tabs are added THEN the open tab count is updated`() {
assertEquals(0, settings.openTabsCount)
store.dispatch(
TabListAction.AddMultipleTabsAction(listOf(
createTab("https://mozilla.org"),
createTab("https://firefox.com"))
)
).joinBlocking()
assertEquals(2, settings.openTabsCount)
}
@Test
fun `WHEN a tab is removed THEN the open tab count is updated`() {
store.dispatch(
TabListAction.AddMultipleTabsAction(listOf(
createTab(id = "1", url = "https://mozilla.org"),
createTab(id = "2", url = "https://firefox.com"))
)
).joinBlocking()
assertEquals(2, settings.openTabsCount)
store.dispatch(TabListAction.RemoveTabAction("1")).joinBlocking()
assertEquals(1, settings.openTabsCount)
}
@Test
fun `WHEN all tabs are removed THEN the open tab count is updated`() {
store.dispatch(
TabListAction.AddMultipleTabsAction(listOf(
createTab("https://mozilla.org"),
createTab("https://firefox.com"))
)
).joinBlocking()
assertEquals(2, settings.openTabsCount)
store.dispatch(TabListAction.RemoveAllTabsAction).joinBlocking()
assertEquals(0, settings.openTabsCount)
}
@Test
fun `WHEN all normal tabs are removed THEN the open tab count is updated`() {
store.dispatch(
TabListAction.AddMultipleTabsAction(listOf(
createTab("https://mozilla.org"),
createTab("https://firefox.com"),
createTab("https://getpocket.com", private = true))
)
).joinBlocking()
assertEquals(2, settings.openTabsCount)
store.dispatch(TabListAction.RemoveAllNormalTabsAction).joinBlocking()
assertEquals(0, settings.openTabsCount)
}
@Test
fun `WHEN tabs are restored THEN the open tab count is updated`() {
assertEquals(0, settings.openTabsCount)
val tabsToRestore = listOf(
createTab("https://mozilla.org"),
createTab("https://firefox.com")
)
store.dispatch(TabListAction.RestoreAction(tabsToRestore)).joinBlocking()
assertEquals(2, settings.openTabsCount)
}
@Test
fun `GIVEN a page is loading WHEN loading is complete THEN we record a UriOpened event`() {
val tab = createTab(id = "1", url = "https://mozilla.org")
store.dispatch(TabListAction.AddTabAction(tab)).joinBlocking()
store.dispatch(ContentAction.UpdateLoadingStateAction(tab.id, true)).joinBlocking()
verify(exactly = 0) { metrics.track(Event.UriOpened) }
store.dispatch(ContentAction.UpdateLoadingStateAction(tab.id, false)).joinBlocking()
verify(exactly = 1) { metrics.track(Event.UriOpened) }
}
@Test
fun `GIVEN a private page is loading WHEN loading is complete THEN we never record a UriOpened event`() {
val tab = createTab(id = "1", url = "https://mozilla.org", private = true)
store.dispatch(TabListAction.AddTabAction(tab)).joinBlocking()
store.dispatch(ContentAction.UpdateLoadingStateAction(tab.id, true)).joinBlocking()
verify(exactly = 0) { metrics.track(Event.UriOpened) }
store.dispatch(ContentAction.UpdateLoadingStateAction(tab.id, false)).joinBlocking()
verify(exactly = 0) { metrics.track(Event.UriOpened) }
}
@Test
fun `GIVEN a load request WHEN no redirect chain is available THEN a new chain will be created`() {
val tab = createTab(id = "1", url = "http://mozilla.org")
store.dispatch(TabListAction.AddTabAction(tab)).joinBlocking()
store.dispatch(ContentAction.UpdateLoadRequestAction(
tab.id, LoadRequestState(tab.content.url, true, true))
).joinBlocking()
assertNull(telemetryMiddleware.redirectChains[tab.id])
store.dispatch(ContentAction.UpdateLoadRequestAction(
tab.id, LoadRequestState("https://mozilla.org", true, true))
).joinBlocking()
assertNotNull(telemetryMiddleware.redirectChains[tab.id])
assertEquals(tab.content.url, telemetryMiddleware.redirectChains[tab.id]!!.root)
}
@Test
fun `GIVEN a load request WHEN a redirect chain is available THEN url is added to chain`() {
val tab = createTab(id = "1", url = "http://mozilla.org")
store.dispatch(TabListAction.AddTabAction(tab)).joinBlocking()
store.dispatch(ContentAction.UpdateLoadRequestAction(
tab.id, LoadRequestState("https://mozilla.org", true, true))
).joinBlocking()
assertNotNull(telemetryMiddleware.redirectChains[tab.id])
assertEquals(tab.content.url, telemetryMiddleware.redirectChains[tab.id]!!.root)
assertEquals("https://mozilla.org", telemetryMiddleware.redirectChains[tab.id]!!.chain.first())
}
@Test
fun `GIVEN a location update WHEN no redirect chain is available THEN no ads telemetry is recorded`() {
val tab = createTab(id = "1", url = "http://mozilla.org")
store.dispatch(ContentAction.UpdateUrlAction(tab.id, "http://mozilla.org")).joinBlocking()
verify(exactly = 0) { adsTelemetry.trackAdClickedMetric(any(), any()) }
}
@Test
fun `GIVEN a location update WHEN a redirect chain is available THEN ads telemetry is recorded`() {
val tab = createTab(id = "1", url = "http://mozilla.org")
store.dispatch(TabListAction.AddTabAction(tab)).joinBlocking()
store.dispatch(ContentAction.UpdateLoadRequestAction(
tab.id, LoadRequestState("https://mozilla.org", true, true))
).joinBlocking()
store.dispatch(ContentAction.UpdateUrlAction(tab.id, "https://mozilla.org")).joinBlocking()
verify(exactly = 1) { adsTelemetry.trackAdClickedMetric(tab.content.url, listOf("https://mozilla.org")) }
}
@Test
fun `GIVEN a location update WHEN ads telemetry is recorded THEN redirect chain is reset`() {
val tab = createTab(id = "1", url = "http://mozilla.org")
store.dispatch(TabListAction.AddTabAction(tab)).joinBlocking()
store.dispatch(ContentAction.UpdateLoadRequestAction(
tab.id, LoadRequestState("https://mozilla.org", true, true))
).joinBlocking()
assertNotNull(telemetryMiddleware.redirectChains[tab.id])
store.dispatch(ContentAction.UpdateUrlAction(tab.id, "https://mozilla.org")).joinBlocking()
assertNull(telemetryMiddleware.redirectChains[tab.id])
}
}
/* 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.browser
import androidx.lifecycle.LifecycleOwner
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import mozilla.components.browser.session.Session
import mozilla.components.browser.session.SessionManager
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Before
import org.junit.Test
import org.mozilla.fenix.components.metrics.Event
import org.mozilla.fenix.components.metrics.MetricController
import org.mozilla.fenix.search.telemetry.ads.AdsTelemetry
import org.mozilla.fenix.utils.Settings
class TelemetrySessionObserverTest {