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
- Feature code emits domain events only - Cleaner, more readable feature logic
- Instrumentation logic is centralized - Easy to audit and modify
- 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:
- Define events in the shared package as an enum (e.g.,
MyFeatureEvent) - Create an EventMapper in the app target that switches on events and fires the appropriate pixels
- 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
- Name methods after domain events, not pixels: Use
purchaseAttemptStarted, notfireSubscriptionPurchaseAttemptPixel. - Keep the protocol focused: One protocol per feature/flow. Don't create a mega-protocol for all app instrumentation.
- Hide implementation details: The protocol shouldn't expose whether something is a daily pixel, unique pixel, or wide event.
- Document expected call order: If methods must be called in sequence (e.g.,
startPurchasebeforecompletePurchase), document this in the protocol.
Related Documentation
pixels.mdc- One-off instrumentation events