208 lines
7.4 KiB
Markdown
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
|