Skip to content

Migrate to use send() extension function#7847

Open
marcosholgado wants to merge 1 commit intofeature/marcos/remove_ext_function_metricfrom
feature/marcos/use_send_ext_funct
Open

Migrate to use send() extension function#7847
marcosholgado wants to merge 1 commit intofeature/marcos/remove_ext_function_metricfrom
feature/marcos/use_send_ext_funct

Conversation

@marcosholgado
Copy link
Contributor

@marcosholgado marcosholgado commented Mar 2, 2026

Task/Issue URL: https://app.asana.com/1/137249556945/project/1208889145294658/task/1213495482913540?focus=true

Description

This PR introduces a send() extension function on MetricsPixel as the single public API for firing experiment metric pixels, and migrates all existing callers to use it. It does also delete the metrics pixel interceptor.

Also changed send() to not return a Boolean since it can send multiple pixels and could get confusing plus we don't really know if the pixel was sent or not.

The interceptor logic is now simplified in the send() implementation. Before we were intercepting pixels and checking the metric we were trying to send was actually defined (to avoid people just firing whatever they wanted). This is now not possible because the only way is to fire through the send() extension function.

Steps to test this PR

To trigger the experiment, add the following temporary code and point the app at the remote config below.

Remote config: https://gist.githubusercontent.com/marcosholgado/cfcc2a9941fa667c68bbf85cc8ac3ac9/raw/6311d66e36960c0a7b8f6db3cd8c51cac7ee7563/extension.json

Trigger code (add temporarily to any impl module, for example in ContentScopeExperimentsFeature):

@ContributesRemoteFeature(
    scope = AppScope::class,
    featureName = "experimentTest",
)
interface experimentTest {
    @Toggle.DefaultValue(DefaultFeatureValue.TRUE)
    fun self(): Toggle

    @Toggle.DefaultValue(DefaultFeatureValue.TRUE)
    fun experimentTestAA(): Toggle

    enum class Cohorts(override val cohortName: String) : CohortName {
        CONTROL("control"),
        TREATMENT("treatment"),
    }
}

@ContributesMultibinding(
    scope = AppScope::class,
    boundType = MainProcessLifecycleObserver::class,
)
class TestObserver @Inject constructor(
    @AppCoroutineScope val appCoroutineScope: CoroutineScope,
    private val experimentTest: experimentTest,
) : MainProcessLifecycleObserver {

    override fun onStart(owner: LifecycleOwner) {
        super.onStart(owner)
        appCoroutineScope.launch {
            experimentTest.experimentTestAA().enroll()
        }
    }
}
  • Build and launch the app with the remote config and trigger code in place
  • Verify in logcat pixel sent that enrollment pixel is sent
  • Perform a search and confirm the experiment metric pixel fires for value=1 and metric=search with the correct cohort, metric name, value, and conversion window params.
  • Perform another search and confirm the search metric pixel is not fired.
  • A metric pixel should be fire on search number 6.
  • Remove the trigger code and verify the build is clean with no lint errors

Note

Medium Risk
Changes the experiment metrics pixel sending path by removing the network interceptor and routing all metric pixels through the new send() implementation, which could impact experiment analytics if conversion-window or dedupe behavior differs. Call-site updates are broad but mechanically straightforward and covered by updated tests.

Overview
Unifies experiment metric pixel firing behind the public MetricsPixel.send() API and migrates existing callers (AppTP DeviceShieldPixels, refresh-pattern pixels, and blocklist privacy toggle) from manual getPixelDefinitions() iteration to a single send() call.

Removes the OkHttp MetricPixelInterceptor (and its tests) and shifts conversion-window/dedup/count-threshold handling into the feature-toggles-impl sender; metric definitions for retention now explicitly use MetricType.COUNT_WHEN_IN_WINDOW.

Updates tests to assert metrics were sent via FakeMetricsPixelExtension rather than verifying low-level Pixel.fire calls, and tightens the lint rule to only ban direct imports of MetricsPixelExtension/MetricsPixelExtensionProvider by type name.

Written by Cursor Bugbot for commit ab83106. This will update automatically on new commits. Configure here.

@marcosholgado marcosholgado mentioned this pull request Mar 2, 2026
2 tasks
Copy link
Contributor Author

Warning

This pull request is not mergeable via GitHub because a downstack PR is open. Once all requirements are satisfied, merge this PR as a stack on Graphite.
Learn more

This stack of pull requests is managed by Graphite. Learn more about stacking.

@marcosholgado marcosholgado force-pushed the feature/marcos/use_send_ext_funct branch from 67ba609 to ab83106 Compare March 3, 2026 10:56
@marcosholgado marcosholgado marked this pull request as ready for review March 4, 2026 11:07
@marcosholgado marcosholgado requested a review from anikiki March 4, 2026 11:07
Copy link

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Migration to send() skips conversion windows due to short-circuit
    • Replaced short-circuiting any with full iteration in RealMetricsPixelSender.send so every pixel definition is processed while still returning whether any definition fired, and added regression tests for overlapping conversion windows.

Create PR

Or push these changes by commenting:

@cursor push 1561d0b17c
Preview (1561d0b17c)
diff --git a/feature-toggles/feature-toggles-impl/src/main/java/com/duckduckgo/feature/toggles/impl/RealMetricsPixelSender.kt b/feature-toggles/feature-toggles-impl/src/main/java/com/duckduckgo/feature/toggles/impl/RealMetricsPixelSender.kt
--- a/feature-toggles/feature-toggles-impl/src/main/java/com/duckduckgo/feature/toggles/impl/RealMetricsPixelSender.kt
+++ b/feature-toggles/feature-toggles-impl/src/main/java/com/duckduckgo/feature/toggles/impl/RealMetricsPixelSender.kt
@@ -50,13 +50,16 @@
         val definitions = metricsPixel.getPixelDefinitions()
         if (definitions.isEmpty()) return false
         return withContext(dispatcherProvider.io()) {
-            definitions.any { definition ->
-                when (metricsPixel.type) {
+            var sent = false
+            definitions.forEach { definition ->
+                val definitionSent = when (metricsPixel.type) {
                     MetricType.NORMAL -> sendNormal(definition)
                     MetricType.COUNT_WHEN_IN_WINDOW -> sendCount(definition, metricsPixel.value.toInt())
                     MetricType.COUNT_ALWAYS -> false // TODO: not implemented yet
                 }
+                sent = sent || definitionSent
             }
+            sent
         }
     }
 

diff --git a/feature-toggles/feature-toggles-impl/src/test/java/com/duckduckgo/feature/toggles/impl/RealMetricsPixelSenderTest.kt b/feature-toggles/feature-toggles-impl/src/test/java/com/duckduckgo/feature/toggles/impl/RealMetricsPixelSenderTest.kt
--- a/feature-toggles/feature-toggles-impl/src/test/java/com/duckduckgo/feature/toggles/impl/RealMetricsPixelSenderTest.kt
+++ b/feature-toggles/feature-toggles-impl/src/test/java/com/duckduckgo/feature/toggles/impl/RealMetricsPixelSenderTest.kt
@@ -93,6 +93,21 @@
     }
 
     @Test
+    fun `NORMAL - fires all matching conversion windows in a single send`() = runTest {
+        val pixel = metricsPixel(
+            type = MetricType.NORMAL,
+            conversionWindow = listOf(
+                ConversionWindow(lowerWindow = 0, upperWindow = 1),
+                ConversionWindow(lowerWindow = 0, upperWindow = 2),
+            ),
+        )
+
+        sender.send(pixel)
+
+        assertEquals(2, fakePixel.firedPixels.size)
+    }
+
+    @Test
     fun `NORMAL - returns false when no cohort assigned`() = runTest {
         testFeature.experimentFooFeature().setRawStoredState(
             State(remoteEnableState = true, enable = true, assignedCohort = null),
@@ -140,6 +155,22 @@
     }
 
     @Test
+    fun `COUNT_WHEN_IN_WINDOW - fires all matching conversion windows when threshold reached`() = runTest {
+        val pixel = metricsPixel(
+            type = MetricType.COUNT_WHEN_IN_WINDOW,
+            value = "2",
+            conversionWindow = listOf(
+                ConversionWindow(lowerWindow = 0, upperWindow = 1),
+                ConversionWindow(lowerWindow = 0, upperWindow = 2),
+            ),
+        )
+
+        repeat(2) { sender.send(pixel) }
+
+        assertEquals(2, fakePixel.firedPixels.size)
+    }
+
+    @Test
     fun `COUNT_WHEN_IN_WINDOW - pixel not fired again after threshold`() = runTest {
         val pixel = metricsPixel(type = MetricType.COUNT_WHEN_IN_WINDOW, value = "3", lowerWindow = 0, upperWindow = 1)
         repeat(5) { sender.send(pixel) }
@@ -206,11 +237,12 @@
         value: String = "1",
         lowerWindow: Int = 0,
         upperWindow: Int = 1,
+        conversionWindow: List<ConversionWindow> = listOf(ConversionWindow(lowerWindow, upperWindow)),
     ) = MetricsPixel(
         metric = "test_metric",
         value = value,
         toggle = testFeature.experimentFooFeature(),
-        conversionWindow = listOf(ConversionWindow(lowerWindow, upperWindow)),
+        conversionWindow = conversionWindow,
         type = type,
     )
 }

@marcosholgado marcosholgado force-pushed the feature/marcos/use_send_ext_funct branch from ab83106 to 439483e Compare March 4, 2026 11:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants