Skip to content

Commit ae0269e

Browse files
committed
Integrate and emit custom stats pixel
1 parent cd82cdb commit ae0269e

File tree

10 files changed

+1497
-0
lines changed

10 files changed

+1497
-0
lines changed

pir/pir-impl/build.gradle

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ dependencies {
7070
testImplementation AndroidX.test.ext.junit
7171
testImplementation Testing.robolectric
7272
testImplementation "androidx.lifecycle:lifecycle-runtime-testing:_"
73+
testImplementation AndroidX.work.testing
7374
testImplementation(KotlinX.coroutines.test) {
7475
// https://github.com/Kotlin/kotlinx.coroutines/issues/2023
7576
// conflicts with mockito due to direct inclusion of byte buddy
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
/*
2+
* Copyright (c) 2025 DuckDuckGo
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.duckduckgo.pir.impl.pixels
18+
19+
import com.duckduckgo.common.utils.DispatcherProvider
20+
import com.duckduckgo.di.scopes.AppScope
21+
import com.duckduckgo.pir.impl.models.scheduling.JobRecord.OptOutJobRecord.OptOutJobStatus.REMOVED
22+
import com.duckduckgo.pir.impl.models.scheduling.JobRecord.OptOutJobRecord.OptOutJobStatus.REQUESTED
23+
import com.duckduckgo.pir.impl.store.PirSchedulingRepository
24+
import com.squareup.anvil.annotations.ContributesBinding
25+
import kotlinx.coroutines.withContext
26+
import java.util.concurrent.TimeUnit
27+
import javax.inject.Inject
28+
import kotlin.math.round
29+
30+
interface OptOutSubmitRateCalculator {
31+
/**
32+
* Calculates the opt-out 24h submit rate for a given broker within the specified date range.
33+
*
34+
* @param brokerName name of the broker to calculate the opt-out submit rate for.
35+
* @param startDateMs The opt-out records to include should be created on or after this date. Default is 0L (epoch).
36+
* @param endDateMs tThe opt-out records to include should be created on or before this date. Default is 0L (epoch).
37+
*/
38+
suspend fun calculateOptOutSubmitRate(
39+
brokerName: String,
40+
startDateMs: Long = 0L,
41+
endDateMs: Long,
42+
): Double?
43+
}
44+
45+
@ContributesBinding(AppScope::class)
46+
class RealOptOutSubmitRateCalculator @Inject constructor(
47+
private val dispatcherProvider: DispatcherProvider,
48+
private val schedulingRepository: PirSchedulingRepository,
49+
) : OptOutSubmitRateCalculator {
50+
override suspend fun calculateOptOutSubmitRate(
51+
brokerName: String,
52+
startDateMs: Long,
53+
endDateMs: Long,
54+
): Double? = withContext(dispatcherProvider.io()) {
55+
// Get all opt out job records created within the given range for the specified broker
56+
val recordsCreatedWithinRange = schedulingRepository.getAllValidOptOutJobRecordsForBroker(brokerName).filter {
57+
it.brokerName == brokerName && it.dateCreatedInMillis in startDateMs..endDateMs
58+
}
59+
60+
// We don't need to calculate the rate if there are no records
61+
if (recordsCreatedWithinRange.isEmpty()) return@withContext null
62+
63+
// Filter the records to only include those that were requested within 24 hours of creation
64+
val requestedRecordsWithinRange = recordsCreatedWithinRange.filter {
65+
(it.status == REQUESTED || it.status == REMOVED) && it.optOutRequestedDateInMillis > it.dateCreatedInMillis &&
66+
it.optOutRequestedDateInMillis <= it.dateCreatedInMillis + TimeUnit.HOURS.toMillis(
67+
24,
68+
)
69+
}
70+
71+
val optOutSuccessRate = requestedRecordsWithinRange.size.toDouble() / recordsCreatedWithinRange.size.toDouble()
72+
val roundedOptOutSuccessRate = round(optOutSuccessRate * 100) / 100
73+
return@withContext roundedOptOutSuccessRate
74+
}
75+
}
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
/*
2+
* Copyright (c) 2025 DuckDuckGo
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.duckduckgo.pir.impl.pixels
18+
19+
import android.content.Context
20+
import androidx.work.CoroutineWorker
21+
import androidx.work.WorkerParameters
22+
import com.duckduckgo.anvil.annotations.ContributesWorker
23+
import com.duckduckgo.common.utils.CurrentTimeProvider
24+
import com.duckduckgo.di.scopes.AppScope
25+
import com.duckduckgo.pir.impl.store.PirRepository
26+
import java.util.concurrent.TimeUnit
27+
import javax.inject.Inject
28+
import kotlin.math.abs
29+
30+
@ContributesWorker(AppScope::class)
31+
class PirCustomStatsWorker(
32+
context: Context,
33+
workerParameters: WorkerParameters,
34+
) : CoroutineWorker(context, workerParameters) {
35+
@Inject
36+
lateinit var optOutSubmitRateCalculator: OptOutSubmitRateCalculator
37+
38+
@Inject
39+
lateinit var pirRepository: PirRepository
40+
41+
@Inject
42+
lateinit var currentTimeProvider: CurrentTimeProvider
43+
44+
@Inject
45+
lateinit var pirPixelSender: PirPixelSender
46+
47+
override suspend fun doWork(): Result {
48+
val startDate = pirRepository.getCustomStatsPixelsLastSentMs()
49+
val now = currentTimeProvider.currentTimeMillis()
50+
51+
if (shouldFirePixel(startDate, now)) {
52+
val endDate = now - TimeUnit.HOURS.toMillis(24)
53+
val activeBrokers = pirRepository.getAllActiveBrokerObjects()
54+
val hasUserProfiles = pirRepository.getAllUserProfileQueries().isNotEmpty()
55+
56+
if (activeBrokers.isNotEmpty() && hasUserProfiles) {
57+
activeBrokers.forEach {
58+
val successRate = optOutSubmitRateCalculator.calculateOptOutSubmitRate(
59+
it.name,
60+
startDate,
61+
endDate,
62+
)
63+
64+
if (successRate != null) {
65+
pirPixelSender.reportBrokerCustomStateOptOutSubmitRate(
66+
brokerUrl = it.url,
67+
optOutSuccessRate = successRate,
68+
)
69+
}
70+
}
71+
72+
pirRepository.setCustomStatsPixelsLastSentMs(endDate)
73+
}
74+
}
75+
76+
return Result.success()
77+
}
78+
79+
private fun shouldFirePixel(
80+
startDate: Long,
81+
now: Long,
82+
): Boolean {
83+
return if (startDate == 0L) {
84+
// IF first run, we emit the custom stats pixel
85+
true
86+
} else {
87+
// Else we check if at least 24 hours have passed since last emission
88+
val nowDiffFromStart = abs(now - startDate)
89+
nowDiffFromStart > TimeUnit.HOURS.toMillis(24)
90+
}
91+
}
92+
93+
companion object {
94+
const val TAG_PIR_RECURRING_CUSTOM_STATS = "TAG_PIR_RECURRING_CUSTOM_STATS"
95+
}
96+
}

pir/pir-impl/src/main/java/com/duckduckgo/pir/impl/scan/PirScanScheduler.kt

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import androidx.work.Data
2424
import androidx.work.ExistingPeriodicWorkPolicy
2525
import androidx.work.NetworkType
2626
import androidx.work.PeriodicWorkRequest
27+
import androidx.work.PeriodicWorkRequestBuilder
2728
import androidx.work.WorkManager
2829
import androidx.work.multiprocess.RemoteListenableWorker
2930
import com.duckduckgo.app.di.AppCoroutineScope
@@ -34,6 +35,8 @@ import com.duckduckgo.pir.impl.common.PirJobConstants.EMAIL_CONFIRMATION_INTERVA
3435
import com.duckduckgo.pir.impl.common.PirJobConstants.SCHEDULED_SCAN_INTERVAL_HOURS
3536
import com.duckduckgo.pir.impl.email.PirEmailConfirmationRemoteWorker
3637
import com.duckduckgo.pir.impl.email.PirEmailConfirmationRemoteWorker.Companion.TAG_EMAIL_CONFIRMATION
38+
import com.duckduckgo.pir.impl.pixels.PirCustomStatsWorker
39+
import com.duckduckgo.pir.impl.pixels.PirCustomStatsWorker.Companion.TAG_PIR_RECURRING_CUSTOM_STATS
3740
import com.duckduckgo.pir.impl.pixels.PirPixelSender
3841
import com.duckduckgo.pir.impl.scan.PirScheduledScanRemoteWorker.Companion.TAG_SCHEDULED_SCAN
3942
import com.duckduckgo.pir.impl.store.PirEventsRepository
@@ -66,6 +69,7 @@ class RealPirScanScheduler @Inject constructor(
6669

6770
schedulePirScans()
6871
scheduleEmailConfirmation()
72+
scheduleRecurringPixelStats()
6973
}
7074

7175
private fun schedulePirScans() {
@@ -129,9 +133,22 @@ class RealPirScanScheduler @Inject constructor(
129133
)
130134
}
131135

136+
private fun scheduleRecurringPixelStats() {
137+
val periodicWorkRequest = PeriodicWorkRequestBuilder<PirCustomStatsWorker>(5, TimeUnit.HOURS)
138+
.addTag(TAG_PIR_RECURRING_CUSTOM_STATS)
139+
.build()
140+
141+
workManager.enqueueUniquePeriodicWork(
142+
TAG_PIR_RECURRING_CUSTOM_STATS,
143+
ExistingPeriodicWorkPolicy.UPDATE,
144+
periodicWorkRequest,
145+
)
146+
}
147+
132148
override fun cancelScheduledScans(context: Context) {
133149
workManager.cancelUniqueWork(TAG_SCHEDULED_SCAN)
134150
workManager.cancelUniqueWork(TAG_EMAIL_CONFIRMATION)
151+
workManager.cancelUniqueWork(TAG_PIR_RECURRING_CUSTOM_STATS)
135152
context.stopService(Intent(context, PirRemoteWorkerService::class.java))
136153
}
137154

pir/pir-impl/src/main/java/com/duckduckgo/pir/impl/store/PirDataStore.kt

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import com.duckduckgo.data.store.api.SharedPreferencesProvider
2222

2323
interface PirDataStore {
2424
var mainConfigEtag: String?
25+
var customStatsPixelsLastSentMs: Long
2526
}
2627

2728
internal class RealPirDataStore(
@@ -42,8 +43,17 @@ internal class RealPirDataStore(
4243
}
4344
}
4445

46+
override var customStatsPixelsLastSentMs: Long
47+
get() = preferences.getLong(KEY_CUSTOM_STATS_PIXEL_LAST_SENT_MS, 0L)
48+
set(value) {
49+
preferences.edit {
50+
putLong(KEY_CUSTOM_STATS_PIXEL_LAST_SENT_MS, value)
51+
}
52+
}
53+
4554
companion object {
4655
private const val FILENAME = "com.duckduckgo.pir.v1"
4756
private const val KEY_MAIN_ETAG = "KEY_MAIN_ETAG"
57+
private const val KEY_CUSTOM_STATS_PIXEL_LAST_SENT_MS = "KEY_CUSTOM_STATS_PIXEL_LAST_SENT_MS"
4858
}
4959
}

pir/pir-impl/src/main/java/com/duckduckgo/pir/impl/store/PirRepository.kt

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,10 @@ interface PirRepository {
174174

175175
suspend fun deleteEmailData(emailData: List<EmailData>)
176176

177+
suspend fun getCustomStatsPixelsLastSentMs(): Long
178+
179+
suspend fun setCustomStatsPixelsLastSentMs(timeMs: Long)
180+
177181
data class GeneratedEmailData(
178182
val emailAddress: String,
179183
val pattern: String,
@@ -663,6 +667,14 @@ class RealPirRepository(
663667
return@withContext
664668
}
665669

670+
override suspend fun getCustomStatsPixelsLastSentMs(): Long = withContext(dispatcherProvider.io()) {
671+
pirDataStore.customStatsPixelsLastSentMs
672+
}
673+
674+
override suspend fun setCustomStatsPixelsLastSentMs(timeMs: Long) = withContext(dispatcherProvider.io()) {
675+
pirDataStore.customStatsPixelsLastSentMs = timeMs
676+
}
677+
666678
private fun List<EmailData>.toRequest(): PirEmailConfirmationDataRequest =
667679
PirEmailConfirmationDataRequest(
668680
items =

pir/pir-impl/src/main/java/com/duckduckgo/pir/impl/store/PirSchedulingRepository.kt

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,11 @@ interface PirSchedulingRepository {
6060
*/
6161
suspend fun getAllValidOptOutJobRecords(): List<OptOutJobRecord>
6262

63+
/**
64+
* Returns all ScanJobRecord whose state is not INVALID for a specific broker
65+
*/
66+
suspend fun getAllValidOptOutJobRecordsForBroker(brokerName: String): List<OptOutJobRecord>
67+
6368
/**
6469
* Returns a matching [OptOutJobRecord] whose state is not INVALID
6570
*
@@ -181,6 +186,16 @@ class RealPirSchedulingRepository @Inject constructor(
181186
.orEmpty()
182187
}
183188

189+
override suspend fun getAllValidOptOutJobRecordsForBroker(brokerName: String): List<OptOutJobRecord> =
190+
withContext(dispatcherProvider.io()) {
191+
return@withContext jobSchedulingDao()
192+
?.getAllOptOutJobRecordsForBroker(brokerName)
193+
?.map { record -> record.toRecord() }
194+
// do not pick-up deprecated jobs as they belong to removed profiles
195+
?.filter { !it.deprecated }
196+
.orEmpty()
197+
}
198+
184199
override suspend fun saveScanJobRecord(scanJobRecord: ScanJobRecord) {
185200
withContext(dispatcherProvider.io()) {
186201
jobSchedulingDao()?.saveScanJobRecord(scanJobRecord.toEntity())

pir/pir-impl/src/main/java/com/duckduckgo/pir/impl/store/db/JobSchedulingDao.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,9 @@ interface JobSchedulingDao {
4040
@Query("SELECT * FROM pir_optout_job_record ORDER BY attemptCount")
4141
fun getAllOptOutJobRecords(): List<OptOutJobRecordEntity>
4242

43+
@Query("SELECT * FROM pir_optout_job_record WHERE brokerName = :brokerName ORDER BY attemptCount")
44+
fun getAllOptOutJobRecordsForBroker(brokerName: String): List<OptOutJobRecordEntity>
45+
4346
@Query("SELECT * FROM pir_optout_job_record ORDER BY attemptCount")
4447
fun getAllOptOutJobRecordsFlow(): Flow<List<OptOutJobRecordEntity>>
4548

0 commit comments

Comments
 (0)