Skip to content

Commit

Permalink
Pixels to measure subscription funnel (#4202)
Browse files Browse the repository at this point in the history
<!--
Note: This checklist is a reminder of our shared engineering
expectations.
The items in Bold are required
If your PR involves UI changes:
1. Upload screenshots or screencasts that illustrate the changes before
/ after
2. Add them under the UI changes section (feel free to add more columns
if needed)
If your PR does not involve UI changes, you can remove the **UI
changes** section

At a minimum, make sure your changes are tested in API 23 and one of the
more recent API levels available.
-->

Task/Issue URL:
https://app.asana.com/0/1205648422731273/1206637905023887/f

### Description

See task.

### No UI changes
  • Loading branch information
lmac012 authored Feb 22, 2024
1 parent 1b53c44 commit 4476062
Show file tree
Hide file tree
Showing 27 changed files with 946 additions and 22 deletions.
1 change: 1 addition & 0 deletions subscriptions/subscriptions-impl/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ dependencies {
implementation project(path: ':macos-api')
implementation project(path: ':windows-api')
implementation project(path: ':downloads-api')
implementation project(path: ':statistics')

implementation AndroidX.appCompat
implementation KotlinX.coroutines.core
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import com.duckduckgo.subscriptions.impl.SubscriptionStatus.Unknown
import com.duckduckgo.subscriptions.impl.SubscriptionsData.*
import com.duckduckgo.subscriptions.impl.billing.BillingClientWrapper
import com.duckduckgo.subscriptions.impl.billing.PurchaseState
import com.duckduckgo.subscriptions.impl.pixels.SubscriptionPixelSender
import com.duckduckgo.subscriptions.impl.repository.AuthRepository
import com.duckduckgo.subscriptions.impl.services.AuthService
import com.duckduckgo.subscriptions.impl.services.CreateAccountResponse
Expand All @@ -40,6 +41,8 @@ import com.duckduckgo.subscriptions.impl.services.ResponseError
import com.duckduckgo.subscriptions.impl.services.StoreLoginBody
import com.duckduckgo.subscriptions.impl.services.SubscriptionsService
import com.squareup.anvil.annotations.ContributesBinding
import com.squareup.moshi.JsonDataException
import com.squareup.moshi.JsonEncodingException
import com.squareup.moshi.Moshi
import dagger.SingleInstanceIn
import javax.inject.Inject
Expand Down Expand Up @@ -142,6 +145,7 @@ class RealSubscriptionsManager @Inject constructor(
private val context: Context,
@AppCoroutineScope private val coroutineScope: CoroutineScope,
private val dispatcherProvider: DispatcherProvider,
private val pixelSender: SubscriptionPixelSender,
) : SubscriptionsManager {

private val adapter = Moshi.Builder().build().adapter(ResponseError::class.java)
Expand Down Expand Up @@ -246,8 +250,11 @@ class RealSubscriptionsManager @Inject constructor(
retries++
}
if (hasSubscription) {
pixelSender.reportPurchaseSuccess()
pixelSender.reportSubscriptionActivated()
_currentPurchaseState.emit(CurrentPurchase.Success)
} else {
pixelSender.reportPurchaseFailureBackend()
_currentPurchaseState.emit(CurrentPurchase.Failure("An error happened, try again"))
}
_hasSubscription.emit(hasSubscription)
Expand Down Expand Up @@ -293,7 +300,11 @@ class RealSubscriptionsManager @Inject constructor(
val storeLoginBody = StoreLoginBody(signature = signature, signedData = body, packageName = context.packageName)
val response = authService.storeLogin(storeLoginBody)
logcat(LogPriority.DEBUG) { "Subs: store login succeeded" }
authenticate(response.authToken)
val subscriptionsData = authenticate(response.authToken)
if (subscriptionsData is Success && subscriptionsData.entitlements.isNotEmpty()) {
pixelSender.reportSubscriptionActivated()
}
subscriptionsData
} else {
Failure(SUBSCRIPTION_NOT_FOUND_ERROR)
}
Expand Down Expand Up @@ -342,11 +353,13 @@ class RealSubscriptionsManager @Inject constructor(
billingClientWrapper.launchBillingFlow(activity, billingParams)
}
} else {
pixelSender.reportRestoreAfterPurchaseAttemptSuccess()
_currentPurchaseState.emit(CurrentPurchase.Recovered)
}
}
is Failure -> {
logcat(LogPriority.ERROR) { "Subs: ${response.message}" }
pixelSender.reportPurchaseFailureOther()
_currentPurchaseState.emit(CurrentPurchase.Failure(response.message))
}
}
Expand All @@ -356,6 +369,11 @@ class RealSubscriptionsManager @Inject constructor(
return try {
val subscriptionData = if (isUserAuthenticated()) {
getSubscriptionDataFromToken(authRepository.tokens().accessToken!!)
.also { subscriptionsData ->
if (subscriptionsData is Success && subscriptionsData.entitlements.isNotEmpty()) {
pixelSender.reportSubscriptionActivated()
}
}
} else {
recoverSubscriptionFromStore()
}
Expand Down Expand Up @@ -436,7 +454,20 @@ class RealSubscriptionsManager @Inject constructor(
}

private suspend fun createAccount(): CreateAccountResponse {
return authService.createAccount("Bearer ${emailManager.getToken()}")
try {
val account = authService.createAccount("Bearer ${emailManager.getToken()}")
if (account.authToken.isEmpty()) {
pixelSender.reportPurchaseFailureAccountCreation()
}
return account
} catch (e: Exception) {
when (e) {
is JsonDataException, is JsonEncodingException, is HttpException -> {
pixelSender.reportPurchaseFailureAccountCreation()
}
}
throw e
}
}

private fun parseError(e: HttpException): ResponseError? {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ import com.duckduckgo.subscriptions.impl.SubscriptionsConstants.LIST_OF_PRODUCTS
import com.duckduckgo.subscriptions.impl.billing.PurchaseState.Canceled
import com.duckduckgo.subscriptions.impl.billing.PurchaseState.InProgress
import com.duckduckgo.subscriptions.impl.billing.PurchaseState.Purchased
import com.duckduckgo.subscriptions.impl.pixels.SubscriptionPixelSender
import com.squareup.anvil.annotations.ContributesBinding
import com.squareup.anvil.annotations.ContributesMultibinding
import dagger.SingleInstanceIn
Expand Down Expand Up @@ -80,6 +81,7 @@ class RealBillingClientWrapper @Inject constructor(
private val context: Context,
val dispatcherProvider: DispatcherProvider,
@AppCoroutineScope val coroutineScope: CoroutineScope,
private val pixelSender: SubscriptionPixelSender,
) : BillingClientWrapper, MainProcessLifecycleObserver {

private var billingFlowInProcess = false
Expand Down Expand Up @@ -109,6 +111,7 @@ class RealBillingClientWrapper @Inject constructor(
}
// Handle an error caused by a user cancelling the purchase flow.
} else {
pixelSender.reportPurchaseFailureStore()
coroutineScope.launch(dispatcherProvider.io()) {
_purchaseState.emit(Canceled)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import com.duckduckgo.js.messaging.api.SubscriptionEventData
import com.duckduckgo.subscriptions.impl.AuthToken
import com.duckduckgo.subscriptions.impl.JSONObjectAdapter
import com.duckduckgo.subscriptions.impl.SubscriptionsManager
import com.duckduckgo.subscriptions.impl.pixels.SubscriptionPixelSender
import com.squareup.anvil.annotations.ContributesBinding
import com.squareup.moshi.Moshi
import javax.inject.Inject
Expand All @@ -52,6 +53,7 @@ class SubscriptionMessagingInterface @Inject constructor(
private val jsMessageHelper: JsMessageHelper,
private val dispatcherProvider: DispatcherProvider,
@AppCoroutineScope private val appCoroutineScope: CoroutineScope,
private val pixelSender: SubscriptionPixelSender,
) : JsMessaging {
private val moshi = Moshi.Builder().add(JSONObjectAdapter()).build()

Expand All @@ -61,7 +63,7 @@ class SubscriptionMessagingInterface @Inject constructor(
private val handlers = listOf(
SubscriptionsHandler(),
GetSubscriptionMessage(subscriptionsManager, dispatcherProvider),
SetSubscriptionMessage(subscriptionsManager, appCoroutineScope, dispatcherProvider),
SetSubscriptionMessage(subscriptionsManager, appCoroutineScope, dispatcherProvider, pixelSender),
)

@JavascriptInterface
Expand Down Expand Up @@ -185,12 +187,15 @@ class SubscriptionMessagingInterface @Inject constructor(
private val subscriptionsManager: SubscriptionsManager,
@AppCoroutineScope private val appCoroutineScope: CoroutineScope,
private val dispatcherProvider: DispatcherProvider,
private val pixelSender: SubscriptionPixelSender,
) : JsMessageHandler {
override fun process(jsMessage: JsMessage, secret: String, jsMessageCallback: JsMessageCallback?) {
try {
val token = jsMessage.params.getString("token")
appCoroutineScope.launch(dispatcherProvider.io()) {
subscriptionsManager.authenticate(token)
pixelSender.reportRestoreUsingEmailSuccess()
pixelSender.reportSubscriptionActivated()
}
} catch (e: Exception) {
logcat { "Error parsing the token" }
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
/*
* Copyright (c) 2024 DuckDuckGo
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package com.duckduckgo.subscriptions.impl.pixels

import com.duckduckgo.app.statistics.pixels.Pixel.PixelType
import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.COUNT
import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.DAILY
import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.UNIQUE
import java.util.EnumSet

enum class SubscriptionPixel(
private val baseName: String,
private val types: Set<PixelType>,
) {
SETTINGS_SUBSCRIPTION_SECTION_SHOWN(
baseName = "m_privacy-pro_app-settings_privacy-pro-section_impression",
type = COUNT,
),
SUBSCRIPTION_ACTIVE(
baseName = "m_privacy-pro_app_subscription_active",
type = DAILY,
),
OFFER_SCREEN_SHOWN(
baseName = "m_privacy-pro_offer_screen_impression",
type = COUNT,
),
OFFER_SUBSCRIBE_CLICK(
baseName = "m_privacy-pro_terms-conditions_subscribe_click",
types = EnumSet.of(COUNT, DAILY),
),
PURCHASE_FAILURE_OTHER(
baseName = "m_privacy-pro_app_subscription-purchase_failure_other",
types = EnumSet.of(COUNT, DAILY),
),
PURCHASE_FAILURE_STORE(
baseName = "m_privacy-pro_app_subscription-purchase_failure_store",
types = EnumSet.of(COUNT, DAILY),
),
PURCHASE_FAILURE_BACKEND(
baseName = "m_privacy-pro_app_subscription-purchase_failure_backend",
types = EnumSet.of(COUNT, DAILY),
),
PURCHASE_FAILURE_ACCOUNT_CREATION(
baseName = "m_privacy-pro_app_subscription-purchase_failure_account-creation",
types = EnumSet.of(COUNT, DAILY),
),
PURCHASE_SUCCESS(
baseName = "m_privacy-pro_app_subscription-purchase_success",
types = EnumSet.of(COUNT, DAILY),
),
OFFER_RESTORE_PURCHASE_CLICK(
baseName = "m_privacy-pro_offer_restore-purchase_click",
type = COUNT,
),
ACTIVATE_SUBSCRIPTION_ENTER_EMAIL_CLICK(
baseName = "m_privacy-pro_activate-subscription_enter-email_click",
types = EnumSet.of(COUNT, DAILY),
),
ACTIVATE_SUBSCRIPTION_RESTORE_PURCHASE_CLICK(
baseName = "m_privacy-pro_activate-subscription_restore-purchase_click",
types = EnumSet.of(COUNT, DAILY),
),
RESTORE_USING_EMAIL_SUCCESS(
baseName = "m_privacy-pro_app_subscription-restore-using-email_success",
types = EnumSet.of(COUNT, DAILY),
),
RESTORE_USING_STORE_SUCCESS(
baseName = "m_privacy-pro_app_subscription-restore-using-store_success",
types = EnumSet.of(COUNT, DAILY),
),
RESTORE_USING_STORE_FAILURE_SUBSCRIPTION_NOT_FOUND(
baseName = "m_privacy-pro_app_subscription-restore-using-store_failure_not-found",
types = EnumSet.of(COUNT, DAILY),
),
RESTORE_USING_STORE_FAILURE_OTHER(
baseName = "m_privacy-pro_app_subscription-restore-using-store_failure_other",
types = EnumSet.of(COUNT, DAILY),
),
RESTORE_AFTER_PURCHASE_ATTEMPT_SUCCESS(
baseName = "m_privacy-pro_app_subscription-restore-after-purchase-attempt_success",
type = COUNT,
),
SUBSCRIPTION_ACTIVATED(
baseName = "m_privacy-pro_app_subscription_activated",
type = UNIQUE,
),
ONBOARDING_ADD_DEVICE_CLICK(
baseName = "m_privacy-pro_welcome_add-device_click",
type = UNIQUE,
),
SETTINGS_ADD_DEVICE_CLICK(
baseName = "m_privacy-pro_settings_add-device_click",
type = COUNT,
),
ADD_DEVICE_ENTER_EMAIL_CLICK(
baseName = "m_privacy-pro_add-device_enter-email_click",
type = COUNT,
),
ONBOARDING_VPN_CLICK(
baseName = "m_privacy-pro_welcome_vpn_click",
type = UNIQUE,
),
ONBOARDING_PIR_CLICK(
baseName = "m_privacy-pro_welcome_personal-information-removal_click",
type = UNIQUE,
),
ONBOARDING_IDTR_CLICK(
baseName = "m_privacy-pro_welcome_identity-theft-restoration_click",
type = UNIQUE,
),
SUBSCRIPTION_SETTINGS_SHOWN(
baseName = "m_privacy-pro_settings_screen_impression",
type = COUNT,
),
APP_SETTINGS_PIR_CLICK(
baseName = "m_privacy-pro_app-settings_personal-information-removal_click",
type = COUNT,
),
APP_SETTINGS_IDTR_CLICK(
baseName = "m_privacy-pro_app-settings_identity-theft-restoration_click",
type = COUNT,
),
SUBSCRIPTION_SETTINGS_CHANGE_PLAN_OR_BILLING_CLICK(
baseName = "m_privacy-pro_settings_change-plan-or-billing_click",
type = COUNT,
),
SUBSCRIPTION_SETTINGS_REMOVE_FROM_DEVICE_CLICK(
baseName = "m_privacy-pro_settings_remove-from-device_click",
type = COUNT,
),
;

constructor(
baseName: String,
type: PixelType,
) : this(baseName, EnumSet.of(type))

fun getPixelNames(): Map<PixelType, String> =
types.associateWith { type -> "${baseName}_${type.pixelNameSuffix}" }
}

private val PixelType.pixelNameSuffix: String
get() = when (this) {
COUNT -> "c"
DAILY -> "d"
UNIQUE -> "u"
}
Loading

0 comments on commit 4476062

Please sign in to comment.