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

208 lines
7.4 KiB
Markdown

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