--- source: ~/DuckDuckGo/apple-browsers.git/main/.cursor/rules/instrumentation-facades.mdc confidence: 0.9 namespace: work last_synced: 2026-04-28 description: Pattern for abstracting pixel and wide event instrumentation behind domain-specific protocols. alwaysApply: false --- # Instrumentation Facades Feature code often becomes verbose when sending many pixels, or mixing pixel calls and wide event lifecycle management. Consider a subscription purchase flow that needs to: - Fire a daily pixel when purchase starts - Start a wide event flow - Update the wide event with timing data - Fire unique pixels on success - Complete the wide event with success/failure/cancelled status - Handle multiple error cases with different failing steps This leads to instrumentation code scattered throughout the feature, making it hard to: - Understand the feature's core logic - Test the feature in isolation - Modify instrumentation without touching feature code - Ensure all instrumentation points are covered We can improve this using the facade pattern, abstracting our instrumentation behind a protocol. This is done by defining a protocol with domain-specific hooks that the feature calls, then implementing the protocol in a dedicated object that handles all instrumentation. ### Benefits 1. **Feature code emits domain events only** - Cleaner, more readable feature logic 2. **Instrumentation logic is centralized** - Easy to audit and modify 3. **Easy to inject mocks for unit testing** - Test feature behavior without pixel dependencies ## File Organization Instrumentation facades should be placed in the **same module as the feature** they instrument: | Component | Location | |-----------|----------| | Protocol | Feature module (e.g., `Subscription/SubscriptionPurchaseInstrumentation.swift`) | | Default Implementation | Same module as protocol | | Mock for Testing | Test target or same module | For features that span iOS and macOS, place the protocol and implementation in a shared package (e.g., `BrowserServicesKit`). ## Pattern Structure ### Step 1: Define the Protocol Create a protocol with methods for each instrumentation hook your feature needs. Name methods after domain events, not pixels: ```swift public protocol SubscriptionPurchaseInstrumentation: AnyObject { func purchaseAttemptStarted(selectionID: String, freeTrialEligible: Bool, ...) func purchaseCancelled() func purchaseFailed(step: FailingStep, error: Error) func activationSucceeded() // ... other domain events } ``` ### Step 2: Implement the Default Class Create an implementation that translates domain events to pixels and wide events. The implementation: - Fires appropriate pixels (standard, daily, or unique) - Manages wide event lifecycle (start, update, complete) - Tracks internal state like the current wide event data ```swift public final class DefaultSubscriptionPurchaseInstrumentation: SubscriptionPurchaseInstrumentation { private let wideEvent: WideEventManaging private var purchaseWideEventData: SubscriptionPurchaseWideEventData? public func purchaseAttemptStarted(...) { DailyPixel.fireDailyAndCount(pixel: .subscriptionPurchaseAttempt, ...) purchaseWideEventData = SubscriptionPurchaseWideEventData(...) wideEvent.startFlow(purchaseWideEventData!) } public func activationSucceeded() { UniquePixel.fire(pixel: .subscriptionActivated) wideEvent.completeFlow(purchaseWideEventData!, status: .success, ...) } } ``` ### Step 3: Use in Feature Code Inject the instrumentation protocol and call it at appropriate points in your feature logic: ```swift final class SubscriptionPurchaseFeature { private let instrumentation: SubscriptionPurchaseInstrumentation func subscriptionSelected(...) async { instrumentation.purchaseAttemptStarted(...) switch await performPurchase() { case .success: instrumentation.activationSucceeded() case .failure(let error): instrumentation.purchaseFailed(step: .accountPayment, error: error) } } } ``` ## Dependency Injection ### Constructor Injection (Preferred) Pass the instrumentation as an init parameter: ```swift final class SubscriptionPurchaseFeature { private let instrumentation: SubscriptionPurchaseInstrumentation init(instrumentation: SubscriptionPurchaseInstrumentation = DefaultSubscriptionPurchaseInstrumentation()) { self.instrumentation = instrumentation } } ``` ### Property Injection For cases where the instrumentation is set after initialization (e.g., UserScripts): ```swift final class DebugUserScript { weak var instrumentation: TabInstrumentationProtocol? } // In the parent object: private let instrumentation = TabInstrumentation() func configureUserScripts() { userScripts.debugScript.instrumentation = instrumentation } ``` ## Testing with Mocks Create a mock that records method calls for verification: ```swift final class MockSubscriptionPurchaseInstrumentation: SubscriptionPurchaseInstrumentation { private(set) var purchaseAttemptStartedCalls: [...] = [] private(set) var activationSucceededCallCount = 0 func purchaseAttemptStarted(...) { purchaseAttemptStartedCalls.append(...) } } ``` Then verify behavior in tests: ```swift func testPurchaseSuccess() async { let mock = MockSubscriptionPurchaseInstrumentation() let feature = SubscriptionPurchaseFeature(instrumentation: mock) await feature.subscriptionSelected(...) XCTAssertEqual(mock.activationSucceededCallCount, 1) } ``` ## Alternative: EventMapping for Shared Packages For features in shared Swift packages that can't directly import `Pixel` or `PixelKit`, use `EventMapping` instead of a full instrumentation facade. See `SharedPackages/BrowserServicesKit/Sources/Common/EventMapping.swift` for the base class. The pattern: 1. **Define events in the shared package** as an enum (e.g., `MyFeatureEvent`) 2. **Create an EventMapper in the app target** that switches on events and fires the appropriate pixels 3. **Inject the EventMapping** into your shared package class See `MaliciousSiteProtectionEventMapper` in `iOS/DuckDuckGo/MaliciousSiteProtection/Events/` for a production example. **When to use EventMapping vs Instrumentation Facades:** - Use **EventMapping** when: Feature is in a shared package, events are simple fire-and-forget - Use **Instrumentation Facades** when: Feature needs wide event lifecycle management, complex state tracking, or many related instrumentation calls ## When to Use Instrumentation Facades Use this pattern when any of the following are true: - A feature has 3+ distinct instrumentation calls - Pixels and wide events are mixed in the same flow - You need to test feature logic without pixel side effects - Instrumentation logic is complex (conditional firing, parameter assembly) Skip this pattern for: - Simple features with 1-2 pixels ## Design Guidelines 1. **Name methods after domain events, not pixels**: Use `purchaseAttemptStarted`, not `fireSubscriptionPurchaseAttemptPixel`. 2. **Keep the protocol focused**: One protocol per feature/flow. Don't create a mega-protocol for all app instrumentation. 3. **Hide implementation details**: The protocol shouldn't expose whether something is a daily pixel, unique pixel, or wide event. 4. **Document expected call order**: If methods must be called in sequence (e.g., `startPurchase` before `completePurchase`), document this in the protocol. ## Related Documentation - `pixels.mdc` - One-off instrumentation events