Files
obsidian-vault/work/wiki/apple-browsers/instrumentation-facades.md
T

7.4 KiB

source, confidence, namespace, last_synced, description, alwaysApply
source confidence namespace last_synced description alwaysApply
~/DuckDuckGo/apple-browsers.git/main/.cursor/rules/instrumentation-facades.mdc 0.9 work 2026-04-28 Pattern for abstracting pixel and wide event instrumentation behind domain-specific protocols. 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:

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
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:

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:

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):

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:

final class MockSubscriptionPurchaseInstrumentation: SubscriptionPurchaseInstrumentation {
    private(set) var purchaseAttemptStartedCalls: [...] = []
    private(set) var activationSucceededCallCount = 0

    func purchaseAttemptStarted(...) {
        purchaseAttemptStartedCalls.append(...)
    }
}

Then verify behavior in tests:

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.
  • pixels.mdc - One-off instrumentation events