Merge pull request #8801 from element-hq/feature/bca/stop_double_reporting_utds

Analyics: stop double reporting posthog utds
This commit is contained in:
Valere 2024-04-05 11:06:55 +02:00 committed by GitHub
commit a1823b0f62
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 234 additions and 10 deletions

View File

@ -0,0 +1,79 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* 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 im.vector.app.features
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import im.vector.app.InstrumentedTest
import im.vector.app.features.analytics.ReportedDecryptionFailurePersistence
import kotlinx.coroutines.test.runTest
import org.amshove.kluent.shouldBeEqualTo
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class ReportedDecryptionFailurePersistenceTest : InstrumentedTest {
private val context = InstrumentationRegistry.getInstrumentation().targetContext
@Test
fun shouldPersistReportedUtds() = runTest {
val persistence = ReportedDecryptionFailurePersistence(context)
persistence.load()
val eventIds = listOf("$0000", "$0001", "$0002", "$0003")
eventIds.forEach {
persistence.markAsReported(it)
}
eventIds.forEach {
persistence.hasBeenReported(it) shouldBeEqualTo true
}
persistence.hasBeenReported("$0004") shouldBeEqualTo false
persistence.persist()
// Load a new one
val persistence2 = ReportedDecryptionFailurePersistence(context)
persistence2.load()
eventIds.forEach {
persistence2.hasBeenReported(it) shouldBeEqualTo true
}
}
@Test
fun testSaturation() = runTest {
val persistence = ReportedDecryptionFailurePersistence(context)
for (i in 1..6000) {
persistence.markAsReported("000$i")
}
// This should have saturated the bloom filter, making the rate of false positives too high.
// A new bloom filter should have been created to avoid that and the recent reported events should still be in the new filter.
for (i in 5800..6000) {
persistence.hasBeenReported("000$i") shouldBeEqualTo true
}
// Old ones should not be there though
for (i in 1..1000) {
persistence.hasBeenReported("000$i") shouldBeEqualTo false
}
}
}

View File

@ -63,6 +63,7 @@ private const val MAX_WAIT_MILLIS = 60_000
class DecryptionFailureTracker @Inject constructor( class DecryptionFailureTracker @Inject constructor(
private val analyticsTracker: AnalyticsTracker, private val analyticsTracker: AnalyticsTracker,
private val sessionDataSource: ActiveSessionDataSource, private val sessionDataSource: ActiveSessionDataSource,
private val decryptionFailurePersistence: ReportedDecryptionFailurePersistence,
private val clock: Clock private val clock: Clock
) : Session.Listener, LiveEventListener { ) : Session.Listener, LiveEventListener {
@ -76,9 +77,6 @@ class DecryptionFailureTracker @Inject constructor(
// Only accessed on a `post` call, ensuring sequential access // Only accessed on a `post` call, ensuring sequential access
private val trackedEventsMap = mutableMapOf<String, DecryptionFailure>() private val trackedEventsMap = mutableMapOf<String, DecryptionFailure>()
// List of eventId that have been reported, to avoid double reporting
private val alreadyReported = mutableListOf<String>()
// Mutex to ensure sequential access to internal state // Mutex to ensure sequential access to internal state
private val mutex = Mutex() private val mutex = Mutex()
@ -98,10 +96,16 @@ class DecryptionFailureTracker @Inject constructor(
this.scope = scope this.scope = scope
} }
observeActiveSession() observeActiveSession()
post {
decryptionFailurePersistence.load()
}
} }
fun stop() { fun stop() {
Timber.v("Stop DecryptionFailureTracker") Timber.v("Stop DecryptionFailureTracker")
post {
decryptionFailurePersistence.persist()
}
activeSessionSourceDisposable.cancel(CancellationException("Closing DecryptionFailureTracker")) activeSessionSourceDisposable.cancel(CancellationException("Closing DecryptionFailureTracker"))
activeSession?.removeListener(this) activeSession?.removeListener(this)
@ -123,6 +127,7 @@ class DecryptionFailureTracker @Inject constructor(
delay(CHECK_INTERVAL) delay(CHECK_INTERVAL)
post { post {
checkFailures() checkFailures()
decryptionFailurePersistence.persist()
currentTicker = null currentTicker = null
if (trackedEventsMap.isNotEmpty()) { if (trackedEventsMap.isNotEmpty()) {
// Reschedule // Reschedule
@ -136,7 +141,7 @@ class DecryptionFailureTracker @Inject constructor(
.distinctUntilChanged() .distinctUntilChanged()
.onEach { .onEach {
Timber.v("Active session changed ${it.getOrNull()?.myUserId}") Timber.v("Active session changed ${it.getOrNull()?.myUserId}")
it.orNull()?.let { session -> it.getOrNull()?.let { session ->
post { post {
onSessionActive(session) onSessionActive(session)
} }
@ -144,7 +149,7 @@ class DecryptionFailureTracker @Inject constructor(
}.launchIn(scope) }.launchIn(scope)
} }
private fun onSessionActive(session: Session) { private suspend fun onSessionActive(session: Session) {
Timber.v("onSessionActive ${session.myUserId} previous: ${activeSession?.myUserId}") Timber.v("onSessionActive ${session.myUserId} previous: ${activeSession?.myUserId}")
val sessionId = session.sessionId val sessionId = session.sessionId
if (sessionId == activeSession?.sessionId) { if (sessionId == activeSession?.sessionId) {
@ -201,7 +206,8 @@ class DecryptionFailureTracker @Inject constructor(
// already tracked // already tracked
return return
} }
if (alreadyReported.contains(eventId)) { if (decryptionFailurePersistence.hasBeenReported(eventId)) {
Timber.v("Event $eventId already reported")
// already reported // already reported
return return
} }
@ -236,7 +242,7 @@ class DecryptionFailureTracker @Inject constructor(
} }
} }
private fun handleEventDecrypted(eventId: String) { private suspend fun handleEventDecrypted(eventId: String) {
Timber.v("Handle event decrypted $eventId time: ${clock.epochMillis()}") Timber.v("Handle event decrypted $eventId time: ${clock.epochMillis()}")
// Only consider if it was tracked as a failure // Only consider if it was tracked as a failure
val trackedFailure = trackedEventsMap[eventId] ?: return val trackedFailure = trackedEventsMap[eventId] ?: return
@ -269,7 +275,7 @@ class DecryptionFailureTracker @Inject constructor(
} }
// This will mutate the trackedEventsMap, so don't call it while iterating on it. // This will mutate the trackedEventsMap, so don't call it while iterating on it.
private fun reportFailure(decryptionFailure: DecryptionFailure) { private suspend fun reportFailure(decryptionFailure: DecryptionFailure) {
Timber.v("Report failure for event ${decryptionFailure.failedEventId}") Timber.v("Report failure for event ${decryptionFailure.failedEventId}")
val error = decryptionFailure.toAnalyticsEvent() val error = decryptionFailure.toAnalyticsEvent()
@ -278,10 +284,10 @@ class DecryptionFailureTracker @Inject constructor(
// now remove from tracked // now remove from tracked
trackedEventsMap.remove(decryptionFailure.failedEventId) trackedEventsMap.remove(decryptionFailure.failedEventId)
// mark as already reported // mark as already reported
alreadyReported.add(decryptionFailure.failedEventId) decryptionFailurePersistence.markAsReported(decryptionFailure.failedEventId)
} }
private fun checkFailures() { private suspend fun checkFailures() {
val now = clock.epochMillis() val now = clock.epochMillis()
Timber.v("Check failures now $now") Timber.v("Check failures now $now")
// report the definitely failed // report the definitely failed

View File

@ -0,0 +1,122 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* 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 im.vector.app.features.analytics
import android.content.Context
import android.util.LruCache
import com.google.common.hash.BloomFilter
import com.google.common.hash.Funnels
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import timber.log.Timber
import java.io.File
import java.io.FileOutputStream
import javax.inject.Inject
private const val REPORTED_UTD_FILE_NAME = "im.vector.analytics.reported_utd"
private const val EXPECTED_INSERTIONS = 5000
/**
* This class is used to keep track of the reported decryption failures to avoid double reporting.
* It uses a bloom filter to limit the memory/disk usage.
*/
class ReportedDecryptionFailurePersistence @Inject constructor(
private val context: Context,
) {
// Keep a cache of recent reported failures in memory.
// They will be persisted to the a new bloom filter if the previous one is getting saturated.
// Should be around 30KB max in memory.
// Also allows to have 0% false positive rate for recent failures.
private val inMemoryReportedFailures: LruCache<String, Unit> = LruCache(300)
// Thread-safe and lock-free.
// The expected insertions is 5000, and expected false positive probability of 3% when close to max capability.
// The persisted size is expected to be around 5KB (100 times less than if it was raw strings).
private var bloomFilter: BloomFilter<String> = BloomFilter.create<String>(Funnels.stringFunnel(Charsets.UTF_8), EXPECTED_INSERTIONS)
/**
* Mark an event as reported.
* @param eventId the event id to mark as reported.
*/
suspend fun markAsReported(eventId: String) {
// Add to in memory cache.
inMemoryReportedFailures.put(eventId, Unit)
bloomFilter.put(eventId)
// check if the filter is getting saturated? and then replace
if (bloomFilter.approximateElementCount() > EXPECTED_INSERTIONS - 500) {
// The filter is getting saturated, and the false positive rate is increasing.
// It's time to replace the filter with a new one. And move the in-memory cache to the new filter.
bloomFilter = BloomFilter.create<String>(Funnels.stringFunnel(Charsets.UTF_8), EXPECTED_INSERTIONS)
inMemoryReportedFailures.snapshot().keys.forEach {
bloomFilter.put(it)
}
persist()
}
Timber.v("## Bloom filter stats: expectedFpp: ${bloomFilter.expectedFpp()}, size: ${bloomFilter.approximateElementCount()}")
}
/**
* Check if an event has been reported.
* @param eventId the event id to check.
* @return true if the event has been reported.
*/
fun hasBeenReported(eventId: String): Boolean {
// First check in memory cache.
if (inMemoryReportedFailures.get(eventId) != null) {
return true
}
return bloomFilter.mightContain(eventId)
}
/**
* Load the reported failures from disk.
*/
suspend fun load() {
withContext(Dispatchers.IO) {
try {
val file = File(context.applicationContext.cacheDir, REPORTED_UTD_FILE_NAME)
if (file.exists()) {
file.inputStream().use {
bloomFilter = BloomFilter.readFrom(it, Funnels.stringFunnel(Charsets.UTF_8))
}
}
} catch (e: Throwable) {
Timber.e(e, "## Failed to load reported failures")
}
}
}
/**
* Persist the reported failures to disk.
*/
suspend fun persist() {
withContext(Dispatchers.IO) {
try {
val file = File(context.applicationContext.cacheDir, REPORTED_UTD_FILE_NAME)
if (!file.exists()) file.createNewFile()
FileOutputStream(file).buffered().use {
bloomFilter.writeTo(it)
}
Timber.v("## Successfully saved reported failures, size: ${file.length()}")
} catch (e: Throwable) {
Timber.e(e, "## Failed to save reported failures")
}
}
}
}

View File

@ -23,6 +23,7 @@ import im.vector.app.test.fakes.FakeAnalyticsTracker
import im.vector.app.test.fakes.FakeClock import im.vector.app.test.fakes.FakeClock
import im.vector.app.test.fakes.FakeSession import im.vector.app.test.fakes.FakeSession
import im.vector.app.test.shared.createTimberTestRule import im.vector.app.test.shared.createTimberTestRule
import io.mockk.coEvery
import io.mockk.every import io.mockk.every
import io.mockk.just import io.mockk.just
import io.mockk.mockk import io.mockk.mockk
@ -60,9 +61,24 @@ class DecryptionFailureTrackerTest {
private val fakeClock = FakeClock() private val fakeClock = FakeClock()
val reportedEvents = mutableSetOf<String>()
private val fakePersistence = mockk<ReportedDecryptionFailurePersistence> {
coEvery { load() } just runs
coEvery { persist() } just runs
coEvery { markAsReported(any()) } coAnswers {
reportedEvents.add(firstArg())
}
every { hasBeenReported(any()) } answers {
reportedEvents.contains(firstArg())
}
}
private val decryptionFailureTracker = DecryptionFailureTracker( private val decryptionFailureTracker = DecryptionFailureTracker(
fakeAnalyticsTracker, fakeAnalyticsTracker,
fakeActiveSessionDataSource.instance, fakeActiveSessionDataSource.instance,
fakePersistence,
fakeClock fakeClock
) )
@ -101,6 +117,7 @@ class DecryptionFailureTrackerTest {
@Before @Before
fun setupTest() { fun setupTest() {
reportedEvents.clear()
fakeMxOrgTestSession.fakeCryptoService.fakeCrossSigningService.givenIsCrossSigningVerifiedReturns(false) fakeMxOrgTestSession.fakeCryptoService.fakeCrossSigningService.givenIsCrossSigningVerifiedReturns(false)
} }