Repository: mozilla-mobile/firefox-ios
Pull request #26144: Add FXIOS-11523 [Homepage] item impression event
Author: login: cyndichin
Created: 2025-04-16T14:35:26Z | Merged: 2025-04-16T20:05:46Z

## :scroll: Tickets
[Jira ticket](https://mozilla-hub.atlassian.net/browse/FXIOS-11523)
[Github issue](https://github.com/mozilla-mobile/firefox-ios/issues/25083)

## :bulb: Description
Add item impression telemetry event. Currently, we only care about certain items being tapped, therefore the show all buttons are not being handled here.
- Added new event `item_viewed`
- Added new action `itemSeen`

Instead of using when the cell is configured like we did in legacy, we add a check to track impression in a couple of places, which is more aligned with the user viewing a cell.
- `viewDidAppear` - initial view
- `scrollViewDidEndDecelerating` - after users scrolls
- `collectionViewDelegate - willDisplay` - if a new cell is about to be viewed

## :pencil: Checklist
You have to check all boxes before merging
- [x] Filled in the above information (tickets numbers and description of your work)
- [x] Updated the PR name to follow our [PR naming guidelines](https://github.com/mozilla-mobile/firefox-ios/wiki/Pull-Request-Naming-Guide)
- [x] Wrote unit tests and/or ensured the tests suite is passing
- [ ] When working on UI, I checked and implemented accessibility (minimum Dynamic Text and VoiceOver)
- [ ] If needed, I updated documentation / comments for complex code and public methods
- [ ] If needed, added a backport comment (example `@Mergifyio backport release/v120`)




Diff:
diff --git a/firefox-ios/Client/Frontend/Home/Homepage Rebuild/HomepageDiffableDataSource.swift b/firefox-ios/Client/Frontend/Home/Homepage Rebuild/HomepageDiffableDataSource.swift
index 553d5b2c28bb7..7f5ef60411661 100644
--- a/firefox-ios/Client/Frontend/Home/Homepage Rebuild/HomepageDiffableDataSource.swift	
+++ b/firefox-ios/Client/Frontend/Home/Homepage Rebuild/HomepageDiffableDataSource.swift	
@@ -60,7 +60,7 @@ final class HomepageDiffableDataSource:
             ]
         }
 
-        var telemetryTappedItemType: HomepageTelemetry.TappedItemType? {
+        var telemetryItemType: HomepageTelemetry.ItemType? {
             switch self {
             case .topSite:
                 return .topSite
diff --git a/firefox-ios/Client/Frontend/Home/Homepage Rebuild/HomepageViewController.swift b/firefox-ios/Client/Frontend/Home/Homepage Rebuild/HomepageViewController.swift
index 55682f529297f..aeb049fa3a0b3 100644
--- a/firefox-ios/Client/Frontend/Home/Homepage Rebuild/HomepageViewController.swift	
+++ b/firefox-ios/Client/Frontend/Home/Homepage Rebuild/HomepageViewController.swift	
@@ -63,6 +63,7 @@ final class HomepageViewController: UIViewController,
     private let overlayManager: OverlayModeManager
     private let logger: Logger
     private let toastContainer: UIView
+    private var alreadyTrackedItems = Set<HomepageItem>()
 
     // MARK: - Initializers
     init(windowUUID: WindowUUID,
@@ -166,11 +167,21 @@ final class HomepageViewController: UIViewController,
         )
     }
 
+    override func viewDidAppear(_ animated: Bool) {
+        super.viewDidAppear(animated)
+        trackVisibleImpressions()
+    }
+
     override func viewWillDisappear(_ animated: Bool) {
         super.viewWillDisappear(animated)
         stopCFRsTimer()
     }
 
+    override func viewDidDisappear(_ animated: Bool) {
+        super.viewDidDisappear(animated)
+        alreadyTrackedItems.removeAll()
+    }
+
     override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
         super.viewWillTransition(to: size, with: coordinator)
         wallpaperView.updateImageForOrientationChange()
@@ -200,6 +211,10 @@ final class HomepageViewController: UIViewController,
         handleToolbarStateOnScroll()
     }
 
+    func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
+        trackVisibleImpressions()
+    }
+
     private func handleScroll(_ scrollView: UIScrollView, isUserInteraction: Bool) {
         // We only handle status bar overlay alpha if there's a wallpaper applied on the homepage
         if homepageState.wallpaperState.wallpaperConfiguration.hasImage {
@@ -480,7 +495,7 @@ final class HomepageViewController: UIViewController,
                 onOpenSyncedTabAction: { [weak self] url in
                     guard let self else { return }
                     self.navigateToNewTab(with: url)
-                    self.sendTappedItemAction(item: item)
+                    self.sendItemActionWithTelemetryExtras(item: item, actionType: .didSelectItem)
                 }
             )
             prepareSyncedTabContextualHint(onCell: syncedTabCell)
@@ -620,7 +635,7 @@ final class HomepageViewController: UIViewController,
         )
     }
 
-    // MARK: Tap Geasutre Recognizer
+    // MARK: Tap Gesture Recognizer
     private func addTapGestureRecognizerToDismissKeyboard() {
         // We want any interaction with the homepage to dismiss the keyboard, including taps
         let tap = UITapGestureRecognizer(target: self, action: #selector(dismissKeyboard))
@@ -829,20 +844,54 @@ final class HomepageViewController: UIViewController,
     /// is handled differently due to how tapping is handled for the cell. See `onOpenSyncedTabAction` in this file.
     private func dispatchDidSelectCardItemAction(with item: HomepageItem) {
         if case .jumpBackInSyncedTab = item { return }
-        sendTappedItemAction(item: item)
+        sendItemActionWithTelemetryExtras(item: item, actionType: .didSelectItem)
     }
 
-    private func sendTappedItemAction(item: HomepageItem) {
-        let telemetryExtras = HomepageTelemetryExtras(itemType: item.telemetryTappedItemType)
+    private func sendItemActionWithTelemetryExtras(item: HomepageItem, actionType: HomepageActionType) {
+        let telemetryExtras = HomepageTelemetryExtras(itemType: item.telemetryItemType)
         store.dispatch(
             HomepageAction(
                 telemetryExtras: telemetryExtras,
                 windowUUID: windowUUID,
-                actionType: HomepageActionType.didSelectItem
+                actionType: actionType
             )
         )
     }
 
+    /// Want to handle tracking here to capture any cells about to viewed,
+    /// but some cells do not get reconfigured so we add additional tracking detection with `trackVisibleImpressions`
+    func collectionView(
+        _ collectionView: UICollectionView,
+        willDisplay cell: UICollectionViewCell,
+        forItemAt indexPath: IndexPath
+    ) {
+        guard let item = dataSource?.itemIdentifier(for: indexPath) else { return }
+        handleTrackingItemImpression(with: item)
+    }
+
+    /// Used to track item impressions. If the user has seen the item on the homepage, we only record the impression once.
+    /// We want to track at initial seen as well as when users scrolls.
+    private func trackVisibleImpressions() {
+        guard let collectionView else {
+            logger.log(
+                "Homepage collectionview should not have been nil, unable to track impression",
+                level: .warning,
+                category: .homepage
+            )
+            return
+        }
+        for indexPath in collectionView.indexPathsForVisibleItems {
+            guard let item = dataSource?.itemIdentifier(for: indexPath) else { continue }
+            handleTrackingItemImpression(with: item)
+        }
+    }
+
+    private func handleTrackingItemImpression(with item: HomepageItem) {
+        guard !alreadyTrackedItems.contains(item) else { return }
+        alreadyTrackedItems.insert(item)
+        sendItemActionWithTelemetryExtras(item: item, actionType: HomepageActionType.itemSeen)
+    }
+
     // MARK: - UIPopoverPresentationControllerDelegate - Context Hints (CFR)
     func popoverPresentationController(
         _ popoverPresentationController: UIPopoverPresentationController,
diff --git a/firefox-ios/Client/Frontend/Home/Homepage Rebuild/Redux/HomepageAction.swift b/firefox-ios/Client/Frontend/Home/Homepage Rebuild/Redux/HomepageAction.swift
index 679730d4055e4..edfaa5adab250 100644
--- a/firefox-ios/Client/Frontend/Home/Homepage Rebuild/Redux/HomepageAction.swift	
+++ b/firefox-ios/Client/Frontend/Home/Homepage Rebuild/Redux/HomepageAction.swift	
@@ -6,7 +6,7 @@ import Common
 import Redux
 
 struct HomepageTelemetryExtras {
-    let itemType: HomepageTelemetry.TappedItemType?
+    let itemType: HomepageTelemetry.ItemType?
 }
 
 final class HomepageAction: Action {
@@ -38,4 +38,5 @@ enum HomepageActionType: ActionType {
     case viewWillAppear
     case didSelectItem
     case embeddedHomepage
+    case itemSeen
 }
diff --git a/firefox-ios/Client/Frontend/Home/Homepage Rebuild/Redux/HomepageMiddleware.swift b/firefox-ios/Client/Frontend/Home/Homepage Rebuild/Redux/HomepageMiddleware.swift
index c8e3a127cacad..e4bd51dd00c4a 100644
--- a/firefox-ios/Client/Frontend/Home/Homepage Rebuild/Redux/HomepageMiddleware.swift	
+++ b/firefox-ios/Client/Frontend/Home/Homepage Rebuild/Redux/HomepageMiddleware.swift	
@@ -7,6 +7,7 @@ import Redux
 
 final class HomepageMiddleware {
     private let homepageTelemetry: HomepageTelemetry
+
     init(homepageTelemetry: HomepageTelemetry = HomepageTelemetry()) {
         self.homepageTelemetry = homepageTelemetry
     }
@@ -37,6 +38,12 @@ final class HomepageMiddleware {
             }
             self.homepageTelemetry.sendItemTappedTelemetryEvent(for: type)
 
+        case HomepageActionType.itemSeen:
+            guard let extras = (action as? HomepageAction)?.telemetryExtras, let type = extras.itemType else {
+                return
+            }
+            self.homepageTelemetry.sendItemImpressionTelemetryEvent(for: type)
+
         default:
             break
         }
diff --git a/firefox-ios/Client/Telemetry/HomepageTelemetry.swift b/firefox-ios/Client/Telemetry/HomepageTelemetry.swift
index 7999d91b793ad..5e04925ec2309 100644
--- a/firefox-ios/Client/Telemetry/HomepageTelemetry.swift
+++ b/firefox-ios/Client/Telemetry/HomepageTelemetry.swift
@@ -6,7 +6,7 @@ import Foundation
 import Glean
 
 struct HomepageTelemetry {
-    enum TappedItemType: String {
+    enum ItemType: String {
         case topSite = "top_site"
         case jumpBackInTab = "jump_back_in_tab"
         case jumpBackInSyncedTab = "jump_back_in_synced_tab"
@@ -44,11 +44,16 @@ struct HomepageTelemetry {
         gleanWrapper.recordEvent(for: GleanMetrics.Homepage.viewed)
     }
 
-    func sendItemTappedTelemetryEvent(for itemType: TappedItemType) {
+    func sendItemTappedTelemetryEvent(for itemType: ItemType) {
         let itemNameExtra = GleanMetrics.Homepage.ItemTappedExtra(section: itemType.sectionName, type: itemType.rawValue)
         gleanWrapper.recordEvent(for: GleanMetrics.Homepage.itemTapped, extras: itemNameExtra)
     }
 
+    func sendItemImpressionTelemetryEvent(for itemType: ItemType) {
+        let itemNameExtra = GleanMetrics.Homepage.ItemViewedExtra(section: itemType.sectionName, type: itemType.rawValue)
+        gleanWrapper.recordEvent(for: GleanMetrics.Homepage.itemViewed, extras: itemNameExtra)
+    }
+
     // MARK: - Top Sites
     enum ContextMenuTelemetryActionType: String {
         case remove, unpin, pin, settings, sponsoredSupport
diff --git a/firefox-ios/Client/metrics.yaml b/firefox-ios/Client/metrics.yaml
index ccbf3538e97f2..d50122a0ad52c 100755
--- a/firefox-ios/Client/metrics.yaml
+++ b/firefox-ios/Client/metrics.yaml
@@ -2389,7 +2389,7 @@ homepage:
   viewed:
     type: event
     description: |
-      Records when the Firefox homepage is viewed.
+      Records when the Firefox homepage is viewed. We consider a `view` to be if a user navigates to a new homepage whether through opening a new tab, navigating from a webpage, or after selecting the address bar. Seeing the homepage again after viewing a modal that is not full screen does not trigger a view.
     bugs:
       - https://github.com/mozilla-mobile/firefox-ios/issues/25082
     data_reviews:
@@ -2409,11 +2409,11 @@ homepage:
       section:
        type: string
        description: |
-        The section that the item belongs to on the homepage. This section name is found in `HomepageTelemetry.TappedItemType` under `sectionName`.
+        The section that the item belongs to on the homepage. This section name is found in `HomepageTelemetry.ItemType` under `sectionName`.
       type:
        type: string
        description: |
-        The type of item that was tapped on the homepage. This name is found in `HomepageTelemetry.TappedItemType`.
+        The type of item that was tapped on the homepage. This name is found in `HomepageTelemetry.ItemType`.
     bugs:
       - https://github.com/mozilla-mobile/firefox-ios/issues/25086
     data_reviews:
@@ -2425,6 +2425,31 @@ homepage:
       tags:
         - Homepage
 
+  item_viewed:
+    type: event
+    description: |
+      Records when an item has been viewed on the homepage. See `homepage.viewed` for more details on what is considered a homepage view. This event refers to an item has been scrolled to or seen on an homepage that has been viewed.
+    extra_keys:
+      section:
+       type: string
+       description: |
+        The section that the item belongs to on the homepage. This section name is found in `HomepageTelemetry.ItemType` under `sectionName`.
+      type:
+       type: string
+       description: |
+        The type of item that was tapped on the homepage. This name is found in `HomepageTelemetry.ItemType`.
+    bugs:
+      - https://github.com/mozilla-mobile/firefox-ios/issues/25083
+    data_reviews:
+      - https://github.com/mozilla-mobile/firefox-ios/pull/26144
+    notification_emails:
+      - fx-ios-data-stewards@mozilla.com
+    expires: "2025-07-01"
+    metadata:
+      tags:
+        - Homepage
+
+
 # Metrics related to the history panel of the library
 library:
   panel_pressed:
diff --git a/firefox-ios/firefox-ios-tests/Tests/ClientTests/Frontend/Homepage Rebuild/HomepageViewControllerTests.swift b/firefox-ios/firefox-ios-tests/Tests/ClientTests/Frontend/Homepage Rebuild/HomepageViewControllerTests.swift
index 8bb204b79a292..31a2e1399a5b4 100644
--- a/firefox-ios/firefox-ios-tests/Tests/ClientTests/Frontend/Homepage Rebuild/HomepageViewControllerTests.swift	
+++ b/firefox-ios/firefox-ios-tests/Tests/ClientTests/Frontend/Homepage Rebuild/HomepageViewControllerTests.swift	
@@ -193,6 +193,64 @@ final class HomepageViewControllerTests: XCTestCase, StoreTestUtility {
         XCTAssertEqual(actionCalled.windowUUID, .XCTestDefaultUUID)
     }
 
+    func test_collectionDelegate_willDisplay_triggersHomepageAction() throws {
+        let subject = createSubject()
+        // Need to call loadViewIfNeeded and newState to populate the datasource
+        // used to check whether we should send dispatch action or not
+        subject.loadViewIfNeeded()
+        subject.newState(state: HomepageState(windowUUID: .XCTestDefaultUUID))
+
+        subject.collectionView(
+            UICollectionView(frame: .zero, collectionViewLayout: UICollectionViewLayout()),
+            willDisplay: UICollectionViewCell(),
+            forItemAt: IndexPath(item: 0, section: 0)
+        )
+
+        let actionCalled = try XCTUnwrap(
+            mockStore.dispatchedActions.last(where: { $0 is HomepageAction }) as? HomepageAction
+        )
+        let actionType = try XCTUnwrap(actionCalled.actionType as? HomepageActionType)
+        XCTAssertEqual(actionType, HomepageActionType.itemSeen)
+        XCTAssertEqual(actionCalled.windowUUID, .XCTestDefaultUUID)
+    }
+
+    func test_viewDidAppear_triggersHomepageAction() throws {
+        let subject = createSubject()
+        // Need to call loadViewIfNeeded and newState to populate the datasource
+        // used to check whether we should send dispatch action or not
+        // layoutIfNeeded() recalculates the collection view to have items
+        subject.loadViewIfNeeded()
+        subject.newState(state: HomepageState(windowUUID: .XCTestDefaultUUID))
+        subject.view.layoutIfNeeded()
+        subject.viewDidAppear(false)
+
+        let actionCalled = try XCTUnwrap(
+            mockStore.dispatchedActions.last(where: { $0 is HomepageAction }) as? HomepageAction
+        )
+        let actionType = try XCTUnwrap(actionCalled.actionType as? HomepageActionType)
+        XCTAssertEqual(actionType, HomepageActionType.itemSeen)
+        XCTAssertEqual(actionCalled.windowUUID, .XCTestDefaultUUID)
+    }
+
+    func test_scrollViewDidEndDecelerating_triggersHomepageAction() throws {
+        let subject = createSubject()
+        // Need to call loadViewIfNeeded and newState to populate the datasource
+        // used to check whether we should send dispatch action or not
+        // layoutIfNeeded() recalculates the collection view to have items
+        subject.loadViewIfNeeded()
+        subject.newState(state: HomepageState(windowUUID: .XCTestDefaultUUID))
+        subject.view.layoutIfNeeded()
+
+        subject.scrollViewDidEndDecelerating(UIScrollView())
+
+        let actionCalled = try XCTUnwrap(
+            mockStore.dispatchedActions.last(where: { $0 is HomepageAction }) as? HomepageAction
+        )
+        let actionType = try XCTUnwrap(actionCalled.actionType as? HomepageActionType)
+        XCTAssertEqual(actionType, HomepageActionType.itemSeen)
+        XCTAssertEqual(actionCalled.windowUUID, .XCTestDefaultUUID)
+    }
+
     private func createSubject(statusBarScrollDelegate: StatusBarScrollDelegate? = nil) -> HomepageViewController {
         let notificationCenter = MockNotificationCenter()
         let themeManager = MockThemeManager()
diff --git a/firefox-ios/firefox-ios-tests/Tests/ClientTests/Frontend/Homepage Rebuild/Redux/HomepageMiddlewareTests.swift b/firefox-ios/firefox-ios-tests/Tests/ClientTests/Frontend/Homepage Rebuild/Redux/HomepageMiddlewareTests.swift
index bccc41b7469a5..b704f87c3a0f6 100644
--- a/firefox-ios/firefox-ios-tests/Tests/ClientTests/Frontend/Homepage Rebuild/Redux/HomepageMiddlewareTests.swift	
+++ b/firefox-ios/firefox-ios-tests/Tests/ClientTests/Frontend/Homepage Rebuild/Redux/HomepageMiddlewareTests.swift	
@@ -174,6 +174,32 @@ final class HomepageMiddlewareTests: XCTestCase, StoreTestUtility {
         XCTAssertEqual(savedExtras.section, "top_sites")
     }
 
+    func test_itemSeenAction_sendTelemetryData() throws {
+        let subject = createSubject()
+        let action = HomepageAction(
+            telemetryExtras: HomepageTelemetryExtras(itemType: .topSite),
+            windowUUID: .XCTestDefaultUUID,
+            actionType: HomepageActionType.itemSeen
+        )
+
+        subject.homepageProvider(AppState(), action)
+
+        let savedMetric = try XCTUnwrap(
+            mockGleanWrapper.savedEvents?[0] as? EventMetricType<GleanMetrics.Homepage.ItemViewedExtra>
+        )
+        let savedExtras = try XCTUnwrap(
+            mockGleanWrapper.savedExtras as? GleanMetrics.Homepage.ItemViewedExtra
+        )
+        let expectedMetricType = type(of: GleanMetrics.Homepage.itemViewed)
+        let resultMetricType = type(of: savedMetric)
+        let debugMessage = TelemetryDebugMessage(expectedMetric: expectedMetricType, resultMetric: resultMetricType)
+
+        XCTAssertEqual(mockGleanWrapper.recordEventCalled, 1)
+        XCTAssert(resultMetricType == expectedMetricType, debugMessage.text)
+        XCTAssertEqual(savedExtras.type, "top_site")
+        XCTAssertEqual(savedExtras.section, "top_sites")
+    }
+
     // MARK: - Helpers
     private func createSubject() -> HomepageMiddleware {
         return HomepageMiddleware(


Reviews:
user: login: ih-codes
body: Thanks Cyndi, here's a first pass!!
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-16T19:15:17Z
user: login: cyndichin
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-16T19:23:28Z
user: login: cyndichin
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-16T19:27:39Z
user: login: cyndichin
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-16T19:29:00Z
user: login: cyndichin
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-16T19:36:47Z
user: login: cyndichin
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-16T19:38:50Z
user: login: cyndichin
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-16T19:42:47Z
user: login: ih-codes
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-16T19:47:58Z
user: login: ih-codes
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-16T19:50:23Z
user: login: cyndichin
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-16T19:58:06Z
user: login: ih-codes
body: Thanks for your work on this @cyndichin !!
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-04-16T19:59:37Z

Review comments:
diff_hunk: @@ -2425,6 +2425,31 @@ homepage:
       tags:
         - Homepage
 
+  item_viewed:
+    type: event
+    description: |
+      Records when an item has been viewed on the homepage.
path: firefox-ios/Client/metrics.yaml
user: login: ih-codes
body: As per our discussion, let's add a qualifying statement here about what we consider a "view"... (i.e. every time the user sees it even after a full screen modal, for example)

Edit: Depends on your response to my other comment about what constitutes a "view" 😆 
created_at: 2025-04-16T18:35:42Z
author_association: COLLABORATOR
diff_hunk: @@ -2425,6 +2425,31 @@ homepage:
       tags:
         - Homepage
 
+  item_viewed:
+    type: event
+    description: |
+      Records when an item has been viewed on the homepage.
+    extra_keys:
+      section:
+       type: string
+       description: |
+        The section that the item belongs to on the homepage. This section name is found in `HomepageTelemetry.TappedItemType` under `sectionName`.
+      type:
+       type: string
+       description: |
+        The type of item that was tapped on the homepage. This name is found in `HomepageTelemetry.TappedItemType`.
path: firefox-ios/Client/metrics.yaml
user: login: ih-codes
body: Great descriptions! 💯 
created_at: 2025-04-16T18:36:22Z
author_association: COLLABORATOR
diff_hunk: @@ -829,20 +844,61 @@ final class HomepageViewController: UIViewController,
     /// is handled differently due to how tapping is handled for the cell. See `onOpenSyncedTabAction` in this file.
     private func dispatchDidSelectCardItemAction(with item: HomepageItem) {
         if case .jumpBackInSyncedTab = item { return }
-        sendTappedItemAction(item: item)
+        sendItemActionWithTelemetryExtras(item: item, actionType: .didSelectItem)
     }
 
-    private func sendTappedItemAction(item: HomepageItem) {
+    private func sendItemActionWithTelemetryExtras(item: HomepageItem, actionType: HomepageActionType) {
         let telemetryExtras = HomepageTelemetryExtras(itemType: item.telemetryTappedItemType)
         store.dispatch(
             HomepageAction(
                 telemetryExtras: telemetryExtras,
                 windowUUID: windowUUID,
-                actionType: HomepageActionType.didSelectItem
+                actionType: actionType
             )
         )
     }
 
+    /// Want to handle tracking here to capture any cells about to viewed,
+    /// but some cells do not get reconfigured so we add additional tracking detection with `trackVisibleImpressions`
+    func collectionView(
+        _ collectionView: UICollectionView,
+        willDisplay cell: UICollectionViewCell,
+        forItemAt indexPath: IndexPath
+    ) {
+        guard let item = dataSource?.itemIdentifier(for: indexPath),
+              !alreadyTrackedItems.contains(item)
+        else {
+            return
+        }
+        handleTrackingItemImpression(with: item)
+    }
+
+    /// Used to track item impressions. If the user has seen the item on the homepage, we only record the impression once.
+    /// We want to track at initial seen as well as when users scrolls.
+    private func trackVisibleImpressions() {
+        guard let collectionView else {
+            logger.log(
+                "Homepage collectionview should not have been nil, unable to track impression",
+                level: .warning,
+                category: .homepage
+            )
+            return
+        }
+        for indexPath in collectionView.indexPathsForVisibleItems {
+            guard let item = dataSource?.itemIdentifier(for: indexPath),
+                  !alreadyTrackedItems.contains(item)
+            else {
+                continue
+            }
path: firefox-ios/Client/Frontend/Home/Homepage Rebuild/HomepageViewController.swift
user: login: ih-codes
body: Since this logic is repeated, maybe it's best to just check right in the `handleTrackingItemImpression` method. Also prevents someone calling `handleTrackingItemImpression` later without remembering to check the `alreadyTrackedItems` array.
created_at: 2025-04-16T18:46:14Z
author_association: COLLABORATOR
diff_hunk: @@ -166,11 +167,21 @@ final class HomepageViewController: UIViewController,
         )
     }
 
+    override func viewDidAppear(_ animated: Bool) {
+        super.viewDidAppear(animated)
+        trackVisibleImpressions()
+    }
+
     override func viewWillDisappear(_ animated: Bool) {
         super.viewWillDisappear(animated)
         stopCFRsTimer()
     }
 
+    override func viewDidDisappear(_ animated: Bool) {
+        super.viewDidDisappear(animated)
+        alreadyTrackedItems.removeAll()
path: firefox-ios/Client/Frontend/Home/Homepage Rebuild/HomepageViewController.swift
user: login: ih-codes
body: I think when you, @lmarceau, and I met earlier, you said @cyndichin that we would trigger an impression every time the user sees the homescreen? Looks like right now the full screen tab tray modal, the settings modal, etc. will not trigger this method, so when the homepage visually reappears to the user we're not getting fresh impression events...

I guess this is similar to what the top sites impressions originally did for the old homepage. Not sure if that's the desired behaviour for these new metrics...? 🤔 
created_at: 2025-04-16T18:49:37Z
author_association: COLLABORATOR
diff_hunk: @@ -49,6 +49,11 @@ struct HomepageTelemetry {
         gleanWrapper.recordEvent(for: GleanMetrics.Homepage.itemTapped, extras: itemNameExtra)
     }
 
+    func sendItemImpressionTelemetryEvent(for itemType: TappedItemType) {
path: firefox-ios/Client/Telemetry/HomepageTelemetry.swift
user: login: ih-codes
body: Wonder if we should rename `TappedItemType` since it's also used for impressions? It's more of just a general `ItemType` now. 🤔 
created_at: 2025-04-16T19:10:28Z
author_association: COLLABORATOR
diff_hunk: @@ -193,6 +193,64 @@ final class HomepageViewControllerTests: XCTestCase, StoreTestUtility {
         XCTAssertEqual(actionCalled.windowUUID, .XCTestDefaultUUID)
     }
 
+    func test_collectionDelegate_willDisplay_triggersHomepageAction() throws {
+        let subject = createSubject()
+        // Need to call loadViewIfNeeded and newState to populate the datasource
+        // used to check whether we should send dispatch action or not
+        subject.loadViewIfNeeded()
+        subject.newState(state: HomepageState(windowUUID: .XCTestDefaultUUID))
+
+        subject.collectionView(
+            UICollectionView(frame: .zero, collectionViewLayout: UICollectionViewLayout()),
+            willDisplay: UICollectionViewCell(),
+            forItemAt: IndexPath(item: 0, section: 0)
+        )
+
+        let actionCalled = try XCTUnwrap(
+            mockStore.dispatchedActions.last(where: { $0 is HomepageAction }) as? HomepageAction
+        )
+        let actionType = try XCTUnwrap(actionCalled.actionType as? HomepageActionType)
+        XCTAssertEqual(actionType, HomepageActionType.itemSeen)
+        XCTAssertEqual(actionCalled.windowUUID, .XCTestDefaultUUID)
+    }
+
+    func test_viewDidAppear_triggersHomepageAction() throws {
+        let subject = createSubject()
+        // Need to call loadViewIfNeeded and newState to populate the datasource
+        // used to check whether we should send dispatch action or not
+        // layoutIfNeeded() recalculates the collection view to have items
+        subject.loadViewIfNeeded()
+        subject.newState(state: HomepageState(windowUUID: .XCTestDefaultUUID))
+        subject.view.layoutIfNeeded()
+        subject.viewDidAppear(false)
+
+        let actionCalled = try XCTUnwrap(
+            mockStore.dispatchedActions.last(where: { $0 is HomepageAction }) as? HomepageAction
+        )
+        let actionType = try XCTUnwrap(actionCalled.actionType as? HomepageActionType)
+        XCTAssertEqual(actionType, HomepageActionType.itemSeen)
+        XCTAssertEqual(actionCalled.windowUUID, .XCTestDefaultUUID)
+    }
+
+    func test_scrollViewDidEndDecelerating_triggersHomepageAction() throws {
+        let subject = createSubject()
+        // Need to call loadViewIfNeeded and newState to populate the datasource
+        // used to check whether we should send dispatch action or not
+        // layoutIfNeeded() recalculates the collection view to have items
+        subject.loadViewIfNeeded()
+        subject.newState(state: HomepageState(windowUUID: .XCTestDefaultUUID))
+        subject.view.layoutIfNeeded()
+
+        subject.scrollViewDidEndDecelerating(UIScrollView())
+
+        let actionCalled = try XCTUnwrap(
+            mockStore.dispatchedActions.last(where: { $0 is HomepageAction }) as? HomepageAction
+        )
+        let actionType = try XCTUnwrap(actionCalled.actionType as? HomepageActionType)
+        XCTAssertEqual(actionType, HomepageActionType.itemSeen)
+        XCTAssertEqual(actionCalled.windowUUID, .XCTestDefaultUUID)
+    }
+
path: firefox-ios/firefox-ios-tests/Tests/ClientTests/Frontend/Homepage Rebuild/HomepageViewControllerTests.swift
user: login: ih-codes
body: Thanks for all the new unit tests too! 🙌 
created_at: 2025-04-16T19:15:02Z
author_association: COLLABORATOR
diff_hunk: @@ -829,20 +844,61 @@ final class HomepageViewController: UIViewController,
     /// is handled differently due to how tapping is handled for the cell. See `onOpenSyncedTabAction` in this file.
     private func dispatchDidSelectCardItemAction(with item: HomepageItem) {
         if case .jumpBackInSyncedTab = item { return }
-        sendTappedItemAction(item: item)
+        sendItemActionWithTelemetryExtras(item: item, actionType: .didSelectItem)
     }
 
-    private func sendTappedItemAction(item: HomepageItem) {
+    private func sendItemActionWithTelemetryExtras(item: HomepageItem, actionType: HomepageActionType) {
         let telemetryExtras = HomepageTelemetryExtras(itemType: item.telemetryTappedItemType)
         store.dispatch(
             HomepageAction(
                 telemetryExtras: telemetryExtras,
                 windowUUID: windowUUID,
-                actionType: HomepageActionType.didSelectItem
+                actionType: actionType
             )
         )
     }
 
+    /// Want to handle tracking here to capture any cells about to viewed,
+    /// but some cells do not get reconfigured so we add additional tracking detection with `trackVisibleImpressions`
+    func collectionView(
+        _ collectionView: UICollectionView,
+        willDisplay cell: UICollectionViewCell,
+        forItemAt indexPath: IndexPath
+    ) {
+        guard let item = dataSource?.itemIdentifier(for: indexPath),
+              !alreadyTrackedItems.contains(item)
+        else {
+            return
+        }
+        handleTrackingItemImpression(with: item)
+    }
+
+    /// Used to track item impressions. If the user has seen the item on the homepage, we only record the impression once.
+    /// We want to track at initial seen as well as when users scrolls.
+    private func trackVisibleImpressions() {
+        guard let collectionView else {
+            logger.log(
+                "Homepage collectionview should not have been nil, unable to track impression",
+                level: .warning,
+                category: .homepage
+            )
+            return
+        }
+        for indexPath in collectionView.indexPathsForVisibleItems {
+            guard let item = dataSource?.itemIdentifier(for: indexPath),
+                  !alreadyTrackedItems.contains(item)
+            else {
+                continue
+            }
path: firefox-ios/Client/Frontend/Home/Homepage Rebuild/HomepageViewController.swift
user: login: cyndichin
body: one guard uses `continue` vs. `return` do you still prefer to consolidate?
created_at: 2025-04-16T19:23:28Z
author_association: CONTRIBUTOR
diff_hunk: @@ -166,11 +167,21 @@ final class HomepageViewController: UIViewController,
         )
     }
 
+    override func viewDidAppear(_ animated: Bool) {
+        super.viewDidAppear(animated)
+        trackVisibleImpressions()
+    }
+
     override func viewWillDisappear(_ animated: Bool) {
         super.viewWillDisappear(animated)
         stopCFRsTimer()
     }
 
+    override func viewDidDisappear(_ animated: Bool) {
+        super.viewDidDisappear(animated)
+        alreadyTrackedItems.removeAll()
path: firefox-ios/Client/Frontend/Home/Homepage Rebuild/HomepageViewController.swift
user: login: cyndichin
body: Ah sorry I didn't explain clearly, we don't trigger if there is a modal. Also, the full screen tab tray modal is only under the experiment, but an impression will trigger if the user views the homepage after. 

![image](https://github.com/user-attachments/assets/677555b6-0568-4415-ab71-556cab83782a)
created_at: 2025-04-16T19:27:38Z
author_association: CONTRIBUTOR
diff_hunk: @@ -49,6 +49,11 @@ struct HomepageTelemetry {
         gleanWrapper.recordEvent(for: GleanMetrics.Homepage.itemTapped, extras: itemNameExtra)
     }
 
+    func sendItemImpressionTelemetryEvent(for itemType: TappedItemType) {
path: firefox-ios/Client/Telemetry/HomepageTelemetry.swift
user: login: cyndichin
body: will update! 
created_at: 2025-04-16T19:29:00Z
author_association: CONTRIBUTOR
diff_hunk: @@ -2425,6 +2425,31 @@ homepage:
       tags:
         - Homepage
 
+  item_viewed:
+    type: event
+    description: |
+      Records when an item has been viewed on the homepage.
path: firefox-ios/Client/metrics.yaml
user: login: cyndichin
body: updated the description, is there a char limit on those lol 🤣 
created_at: 2025-04-16T19:36:47Z
author_association: CONTRIBUTOR
diff_hunk: @@ -829,20 +844,61 @@ final class HomepageViewController: UIViewController,
     /// is handled differently due to how tapping is handled for the cell. See `onOpenSyncedTabAction` in this file.
     private func dispatchDidSelectCardItemAction(with item: HomepageItem) {
         if case .jumpBackInSyncedTab = item { return }
-        sendTappedItemAction(item: item)
+        sendItemActionWithTelemetryExtras(item: item, actionType: .didSelectItem)
     }
 
-    private func sendTappedItemAction(item: HomepageItem) {
+    private func sendItemActionWithTelemetryExtras(item: HomepageItem, actionType: HomepageActionType) {
         let telemetryExtras = HomepageTelemetryExtras(itemType: item.telemetryTappedItemType)
         store.dispatch(
             HomepageAction(
                 telemetryExtras: telemetryExtras,
                 windowUUID: windowUUID,
-                actionType: HomepageActionType.didSelectItem
+                actionType: actionType
             )
         )
     }
 
+    /// Want to handle tracking here to capture any cells about to viewed,
+    /// but some cells do not get reconfigured so we add additional tracking detection with `trackVisibleImpressions`
+    func collectionView(
+        _ collectionView: UICollectionView,
+        willDisplay cell: UICollectionViewCell,
+        forItemAt indexPath: IndexPath
+    ) {
+        guard let item = dataSource?.itemIdentifier(for: indexPath),
+              !alreadyTrackedItems.contains(item)
+        else {
+            return
+        }
+        handleTrackingItemImpression(with: item)
+    }
+
+    /// Used to track item impressions. If the user has seen the item on the homepage, we only record the impression once.
+    /// We want to track at initial seen as well as when users scrolls.
+    private func trackVisibleImpressions() {
+        guard let collectionView else {
+            logger.log(
+                "Homepage collectionview should not have been nil, unable to track impression",
+                level: .warning,
+                category: .homepage
+            )
+            return
+        }
+        for indexPath in collectionView.indexPathsForVisibleItems {
+            guard let item = dataSource?.itemIdentifier(for: indexPath),
+                  !alreadyTrackedItems.contains(item)
+            else {
+                continue
+            }
path: firefox-ios/Client/Frontend/Home/Homepage Rebuild/HomepageViewController.swift
user: login: cyndichin
body: never mind, I understand what you meant, good point! I separated out the check! 
created_at: 2025-04-16T19:38:50Z
author_association: CONTRIBUTOR
diff_hunk: @@ -166,11 +167,21 @@ final class HomepageViewController: UIViewController,
         )
     }
 
+    override func viewDidAppear(_ animated: Bool) {
+        super.viewDidAppear(animated)
+        trackVisibleImpressions()
+    }
+
     override func viewWillDisappear(_ animated: Bool) {
         super.viewWillDisappear(animated)
         stopCFRsTimer()
     }
 
+    override func viewDidDisappear(_ animated: Bool) {
+        super.viewDidDisappear(animated)
+        alreadyTrackedItems.removeAll()
path: firefox-ios/Client/Frontend/Home/Homepage Rebuild/HomepageViewController.swift
user: login: cyndichin
body: When I [spoke](https://mozilla.slack.com/archives/C07PH5RUNAK/p1744130601226849) to John, it didn't seem like it was a new impression because it feels like the same view from the user perspective. We also don't trigger an impression for legacy.
created_at: 2025-04-16T19:42:47Z
author_association: CONTRIBUTOR
diff_hunk: @@ -2425,6 +2425,31 @@ homepage:
       tags:
         - Homepage
 
+  item_viewed:
+    type: event
+    description: |
+      Records when an item has been viewed on the homepage.
path: firefox-ios/Client/metrics.yaml
user: login: ih-codes
body: I'd have to check the docs but I've seen descriptions that are quite long 🤣 
created_at: 2025-04-16T19:47:58Z
author_association: COLLABORATOR
diff_hunk: @@ -166,11 +167,21 @@ final class HomepageViewController: UIViewController,
         )
     }
 
+    override func viewDidAppear(_ animated: Bool) {
+        super.viewDidAppear(animated)
+        trackVisibleImpressions()
+    }
+
     override func viewWillDisappear(_ animated: Bool) {
         super.viewWillDisappear(animated)
         stopCFRsTimer()
     }
 
+    override func viewDidDisappear(_ animated: Bool) {
+        super.viewDidDisappear(animated)
+        alreadyTrackedItems.removeAll()
path: firefox-ios/Client/Frontend/Home/Homepage Rebuild/HomepageViewController.swift
user: login: ih-codes
body: Ok! I'm with @lmarceau in that I think a full screen modal being dismissed should trigger a fresh impression (e.g. user goes to the Settings), but if John is happy with this and that's the previous behaviour, then that's fine! 😆  I guess just clarify in the metrics.yaml description that full screen modals do not trigger a fresh impression so anyone viewing the glean dictionary is understanding of what this probe records.
created_at: 2025-04-16T19:50:23Z
author_association: COLLABORATOR
diff_hunk: @@ -166,11 +167,21 @@ final class HomepageViewController: UIViewController,
         )
     }
 
+    override func viewDidAppear(_ animated: Bool) {
+        super.viewDidAppear(animated)
+        trackVisibleImpressions()
+    }
+
     override func viewWillDisappear(_ animated: Bool) {
         super.viewWillDisappear(animated)
         stopCFRsTimer()
     }
 
+    override func viewDidDisappear(_ animated: Bool) {
+        super.viewDidDisappear(animated)
+        alreadyTrackedItems.removeAll()
path: firefox-ios/Client/Frontend/Home/Homepage Rebuild/HomepageViewController.swift
user: login: cyndichin
body: sounds good! I think this is something we wanted to standardize in terms of the modals right? Is this good enough?
> Seeing the homepage again after viewing a modal that is not full screen does not trigger a view.
created_at: 2025-04-16T19:58:06Z
author_association: CONTRIBUTOR
diff_hunk: @@ -2389,7 +2389,7 @@ homepage:
   viewed:
     type: event
     description: |
-      Records when the Firefox homepage is viewed.
+      Records when the Firefox homepage is viewed. We consider a `view` to be if a user navigates to a new homepage whether through opening a new tab, navigating from a webpage, or after selecting the address bar. Seeing the homepage again after viewing a modal that is not full screen does not trigger a view.
path: firefox-ios/Client/metrics.yaml
user: login: ih-codes
body: Perfect! 👌 
created_at: 2025-04-16T19:59:02Z
author_association: COLLABORATOR
diff_hunk: @@ -2428,16 +2428,16 @@ homepage:
   item_viewed:
     type: event
     description: |
-      Records when an item has been viewed on the homepage.
+      Records when an item has been viewed on the homepage. See `homepage.viewed` for more details on what is considered a homepage view. This event refers to an item has been scrolled to or seen on an homepage that has been viewed.
path: firefox-ios/Client/metrics.yaml
user: login: ih-codes
body: And great! Also glad you remembered to update the `ItemType` everywhere hehe.
created_at: 2025-04-16T19:59:25Z
author_association: COLLABORATOR

PR comments:
user: login: mobiletest-ci-bot
created_at: 2025-04-16T14:46:58Z
body: 
<!--
  0 failure: 
  0 warning: 
  3 messages
  1 markdown notices
  DangerID: danger-id-Danger;
-->



<table>
  <thead>
    <tr>
      <th width="50"></th>
      <th width="100%" data-danger-table="true">Messages</th>
    </tr>
  </thead>
  <tbody><tr>
      <td>:book:</td>
      <td>Project coverage: 36.14%</td>
    </tr>
  
<tr>
      <td>:book:</td>
      <td>Edited 8 files</td>
    </tr>
  
<tr>
      <td>:book:</td>
      <td>Created 0 files</td>
    </tr>
  </tbody>
</table>

## Client.app: Coverage: 34.86
| File | Coverage ||
| --- | --- | --- |
HomepageTelemetry.swift | 98.31% | ✅
HomepageDiffableDataSource.swift | 84.13% | ✅
HomepageViewController.swift | 43.88% | ⚠️
HomepageAction.swift | 100.0% | ✅
HomepageMiddleware.swift | 100.0% | ✅

<p align="right">
  Generated by :no_entry_sign: <a href="https://danger.systems/swift">Danger Swift</a> against 00958451beab7fc686bf0aec6a76ee4542d64453
</p>

author_association: NONE
user: login: cyndichin
created_at: 2025-04-16T18:03:43Z
body: # Comparison
Specifically using sponsored top sites as an example:

Impressions are triggered:
- Load webpage -> tap on home button -> view homepage
- Load webpage -> Add new tab from tab tray -> view homepage 
- Load webpage -> long pressing on tab tray to open new tab -> view homepage
- Select homepage -> View webpage -> navigate back to homepage
- Load webpage -> select on URL -> view homepage

**Other cases**
- If on homepage, show modal (i.e. settings, menu), no impression is triggered

The discrepancy that I found was that with the old homepage, if homepage is already shown, then an impression is triggered. However, in new homepage, an impression is not. Will address this in a follow up PR.
author_association: CONTRIBUTOR
user: login: cyndichin
created_at: 2025-04-16T18:27:51Z
body: # Request for data collection review form

**All questions are mandatory. You must receive review from a data steward peer on your responses to these questions before shipping new data collection.**

1) What questions will you answer with this data?

Clicks and impression are required to understand Click through rate. Which helps us understand if the content we are showing to users is meeting a need or helping them complete a task. The contents usefulness or lack thereof will be measured by increases or decreases in click thru rate.

2) Why does Mozilla need to answer these questions?  Are there benefits for users? Do we need this information to address product or business requirements? Some example responses:

Do users want us to show their bookmarks on the homescreen? Do they want us to recommend articles online they haven’t seen? Do we want to recommend items from history or previous searching sessions as quick links? Understanding Clicks/Impressions will be a core way we answer those questions

3) What alternative methods did you consider to answer these questions? Why were they not sufficient?

No alternative methods as these new events give us the sufficient data to the questions above while following the correct telemetry naming convention for consistency. 

4) Can current instrumentation answer these questions?

Yes for the old homepage, we have existing telemetry events, but will be replacing those old telemetry events with these new ones.

5) List all proposed measurements and indicate the category of data collection for each measurement, using the [Firefox data collection categories](https://wiki.mozilla.org/Data_Collection) found on the Mozilla wiki.   

**Note that the data steward reviewing your request will characterize your data collection based on the highest (and most sensitive) category.**

<table>
  <tr>
    <td>Measurement Description</td>
    <td>Data Collection Category</td>
    <td>Tracking Bug #</td>
  </tr>
  <tr>
    <td>homepage.item_viewed</td>
    <td>2</td>
    <td>FXIOS-11523</td>
  </tr>
</table>

6) Please provide a link to the documentation for this data collection which describes the ultimate data set in a public, complete, and accurate way.

This collection is documented in the Glean Dictionary at https://dictionary.telemetry.mozilla.org/

7) How long will this data be collected?  Choose one of the following:

I want to permanently monitor this data: @jhall@mozilla.com

8) What populations will you measure?

All channels, countries, and locales. No filters.

9) If this data collection is default on, what is the opt-out mechanism for users?

User has the general option of disable telemetry or/and studies.

10) Please provide a general description of how you will analyze this data.

We will analyze aggregated information: items tapped from the homepage and the most common items that were tapped.

11) Where do you intend to share the results of your analysis?

Mozilla Internal (Product and Dev Team).

12) Is there a third-party tool (i.e. not Glean or Telemetry) that you are proposing to use for this data collection? If so:

No. We will collect the info using Glean. We will analyze the data with the traditional Looker/SQL database that Mozilla uses for all other telemetry.

author_association: CONTRIBUTOR
user: login: cyndichin
created_at: 2025-04-16T18:38:40Z
body: @adudenamedruby welcome back! requesting data review!
author_association: CONTRIBUTOR
user: login: cyndichin
created_at: 2025-04-16T18:55:36Z
body: @mergify backport release/v138
author_association: CONTRIBUTOR
user: login: mergify[bot]
created_at: 2025-04-16T18:55:43Z
body: > backport release/v138

#### ✅ Backports have been created

<details>

* [#26153 Add FXIOS-11523 [Homepage] item impression event (backport #26144)](https://github.com/mozilla-mobile/firefox-ios/pull/26153) has been created for branch `release/v138`

</details>


<!---
DO NOT EDIT
-*- Mergify Payload -*-
{"command": "backport release/v138", "conclusion": "success", "action_is_running": true}
-*- Mergify Payload End -*-
-->
author_association: CONTRIBUTOR
user: login: cyndichin
created_at: 2025-04-16T19:56:32Z
body: thanks for the initial review @ih-codes the PR is ready for another 👀 . Let me know if I should improve the descriptions!
author_association: CONTRIBUTOR
user: login: cyndichin
created_at: 2025-04-16T20:01:07Z
body: @ih-codes thanks for the quick + thorough review ❤️ 
author_association: CONTRIBUTOR
user: login: adudenamedruby
created_at: 2025-04-16T20:04:21Z
body: r+
# Data Review Form

_1) Is there or will there be **documentation** that describes the schema for the ultimate data set in a public, complete, and accurate way?_ 

Yes, telemetry is documented at https://dictionary.telemetry.mozilla.org/apps/firefox_ios

_2) Is there a control mechanism that allows the user to turn the data collection on and off?_ 

Yes, Firefox iOS provides data controls in the app settings

_3) If the request is for permanent data collection, is there someone who will monitor the data over time?_

This will be reviewed by a data-steward on the Firefox iOS team (@adudenamedruby) every 6 months

_4) Using the **[category system of data types](https://wiki.mozilla.org/Firefox/Data_Collection)** on the Mozilla wiki, what collection type of data do the requested measurements fall under?_

Type 2

_5) Is the data collection request for default-on or default-off?_

Default on

_6) Does the instrumentation include the addition of **any *new* identifiers** (whether anonymous or otherwise; e.g., username, random IDs, etc.  See the appendix for more details)?_

No

_7) Is the data collection covered by the existing Firefox privacy notice?_ 

Yes

_8) Does the data collection use a third-party collection tool?_ 

No
author_association: CONTRIBUTOR